diff --git a/.github/workflows/sdk-integration.yml b/.github/workflows/sdk-integration.yml index c33976f52..8a3bcd1c6 100644 --- a/.github/workflows/sdk-integration.yml +++ b/.github/workflows/sdk-integration.yml @@ -49,7 +49,7 @@ env: RUST_LOG: opensecret=info TINFOIL_API_KEY: sdk-integration-placeholder VITE_OPEN_SECRET_API_URL: http://127.0.0.1:3000 - VITE_OPEN_SECRET_PCR_ENVIRONMENT: development + VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT: dev VITE_TEST_CLIENT_ID: ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 VITE_TEST_DEVELOPER_EMAIL: sdk-ci-developer@local.test VITE_TEST_DEVELOPER_INVITE_CODE: 11111111-1111-4111-8111-111111111111 diff --git a/.github/workflows/sdk-rust.yml b/.github/workflows/sdk-rust.yml index d892adee0..9251bc57f 100644 --- a/.github/workflows/sdk-rust.yml +++ b/.github/workflows/sdk-rust.yml @@ -52,6 +52,7 @@ jobs: cd sdk/rust cargo fmt --all -- --check cargo clippy --locked --all-targets --all-features -- -D warnings + cargo test --locked --no-default-features --lib local_mock_bypass_is_disabled_without_feature cargo test --locked --all-features --lib RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features ' diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml index bf19f0885..5850be810 100644 --- a/.github/workflows/sdk-typescript.yml +++ b/.github/workflows/sdk-typescript.yml @@ -15,6 +15,7 @@ on: - "sdk/bunfig.toml" - "sdk/eslint.config.js" - "sdk/package.json" + - "sdk/scripts/**" - "sdk/tsconfig.build.json" - "sdk/tsconfig.json" - "sdk/vite.config.ts" @@ -31,6 +32,7 @@ on: - "sdk/bunfig.toml" - "sdk/eslint.config.js" - "sdk/package.json" + - "sdk/scripts/**" - "sdk/tsconfig.build.json" - "sdk/tsconfig.json" - "sdk/vite.config.ts" @@ -66,10 +68,11 @@ jobs: bun run format:check bun run build VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 \ - VITE_OPEN_SECRET_PCR_ENVIRONMENT=development \ + VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=dev \ bun test \ src/lib/test/*.test.ts \ src/lib/test/integration/attestation.test.ts \ + src/lib/test/integration/attestationSession.test.ts \ src/lib/test/integration/developerHook.test.ts \ src/lib/test/integration/liveAttestation.test.ts \ src/lib/test/integration/pcr.test.ts \ diff --git a/README.md b/README.md index 7849e8cba..c505cf4b8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Select the intended OpenSecret API in the ignored `frontend/.env.local`: ```dotenv VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 +VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=dev ``` The public Maple client ID is already present in `.env.example`. All `VITE_*` @@ -53,16 +54,18 @@ just desktop-dev # Tauri desktop, including Agent Mode and native features `just desktop-dev` is preferred over a raw `bun tauri dev`: it provisions the pinned ONNX Runtime and applies a local Tauri configuration overlay when one is -present. +present. It also compiles the explicitly named local mock-attestation feature; +the bypass activates only for a plain-HTTP local endpoint. Normal debug, +release, CI, mobile, Docker, and embedded-proxy builds leave that feature off. ## API configuration `frontend/.env.example` documents Maple's public configuration surface: - `VITE_OPEN_SECRET_API_URL` selects the required OpenSecret backend. -- `VITE_OPEN_SECRET_PCR_ENVIRONMENT` selects the matching PCR0 trust roots; - it defaults to `production`, so hosted development enclaves must set - `development` explicitly. +- `VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT` selects the matching tagged-release + policy; it defaults to `prod`, so hosted development enclaves must set `dev` + explicitly. - `VITE_CLIENT_ID` overrides Maple's public project ID when developing against another OpenSecret project. - `VITE_OS_FLAGS_BASE_URL` selects an optional feature-flags API. @@ -144,7 +147,9 @@ just desktop-build-debug-overlay # Requires .local/tauri-workspace.json ``` Only the overlay recipe applies `.local/tauri-workspace.json` while packaging. -Use it when a checkout-specific bundle identity is part of the smoke test. +Use it when a checkout-specific bundle identity is part of the smoke test. Like +`desktop-dev`, this explicitly local recipe compiles mock attestation support; +the other desktop build recipes do not. Linux desktop builds require the system libraries supplied by the Nix shell. For an already-built binary in a headless display environment, WebKit may need: @@ -208,9 +213,30 @@ Use `.agents/skills/release-maple/` for version parity, tag safety, workflow monitoring, artifact verification, and explicit store handoff. Do not use the legacy `just release` recipe to create an unreviewed local tag. -When the OpenSecret enclave changes, update and review the corresponding -`pcr0DevValues` or `pcr0Values` in `frontend/src/app.tsx` as part of the -attestation compatibility change. +When the OpenSecret enclave changes, publish and verify its tagged Sigstore +release evidence, then promote that exact evidence into the appropriate TUF +channel before deploying the enclave. Maple does not maintain its own PCR +allowlist: before key exchange, the SDK requires the complete PCR0/PCR1/PCR2 +tuple to match a release dynamically authorized for the configured environment +by `attestations.trymaple.ai`. Runtime clients fetch only that fixed-origin TUF +repository; they do not call GitHub, Fulcio, or Rekor. + +The TypeScript and Rust SDKs embed the same TUF bootstrap root, not an ordinary +release snapshot. Normal enclave releases therefore require no SDK release. +Changing the configured release builder, source repository, or CI provider also +does not require an SDK release: those identities are checked before promotion, +while clients authorize the exact TUF-selected evidence. The SDK changes only +when its client contract changes. Normal root rotations arrive through the +authenticated, sequential TUF root chain; replacing or advancing the embedded +bootstrap out of band is forbidden until a reviewed bridge-history migration +exists. Until the production root and initial repository are reviewed and +published, the checked-in placeholder root keeps this draft integration +fail-closed for real enclave connections. + +Packaged Maple selects trust policy only for exact official backend origins. +Supporting an arbitrary hosted backend requires an explicit custom TUF +repository and bootstrap-root integration; a custom URL never inherits the +Maple production trust policy. Version changes update: diff --git a/frontend/.env.example b/frontend/.env.example index 3a25f395c..e4db3828d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,8 +1,10 @@ # Public OpenSecret project id. Optional; the app uses this value by default. VITE_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 -# Defaults to production. Set development explicitly for a hosted development enclave. -#VITE_OPEN_SECRET_PCR_ENVIRONMENT=development +# Selects the tagged OpenSecret release policy. Omit this in production; "prod" is the default. +# "dev" still requires a valid development release for remote APIs. The SDK's development bypass +# is limited to an HTTP API URL whose parsed host is an exact supported local-loopback address. +VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=dev #VITE_OS_FLAGS_BASE_URL=https://flags-dev.opensecret.cloud # Comma-separated local preview flags. Agent connections supports macOS/Linux desktop only. #VITE_FORCE_FEATURE_FLAGS=agent_connections diff --git a/frontend/.env.test b/frontend/.env.test index b029cec2f..304ab598f 100644 --- a/frontend/.env.test +++ b/frontend/.env.test @@ -1,3 +1,4 @@ # VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 VITE_OPEN_SECRET_API_URL=https://enclave.secretgpt.ai +VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=dev VITE_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 diff --git a/frontend/bun.lock b/frontend/bun.lock index 097a2a173..b47a3717f 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -207,6 +207,12 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], + "@freedomofpress/crypto-browser": ["@freedomofpress/crypto-browser@0.1.7", "", { "dependencies": { "@noble/curves": "^1.6.0" } }, "sha512-zjWmZDKdAu8g0Zq1IjBQ+sKQ/NpfzStBDFjy/qHUSMVEL4wNlNGtA7lhtw8v8asXa0yqF2QTYQ3rq6xCTQeADw=="], + + "@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.14", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-1dqc7HojiBcr/sJSAXjBwNz4+WGeeAJP8ENQnDxm+idVV0/YIxpfwXRNSJdNw6EXJuHviq/5kSPEBCxamPcrpQ=="], + + "@freedomofpress/tuf-browser": ["@freedomofpress/tuf-browser@0.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7" } }, "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], @@ -247,7 +253,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@file:../sdk", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "devDependencies": { "@eslint/js": "9.39.5", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", "vite-plugin-dts": "4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }], + "@opensecret/react": ["@opensecret/react@file:../sdk", { "dependencies": { "@freedomofpress/sigstore-browser": "0.1.14", "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "devDependencies": { "@eslint/js": "9.39.5", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", "vite-plugin-dts": "4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], @@ -1311,6 +1317,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@freedomofpress/sigstore-browser/@noble/curves": ["@noble/curves@2.4.0", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew=="], + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], @@ -1453,6 +1461,8 @@ "@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@freedomofpress/sigstore-browser/@noble/curves/@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + "@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 7e17f785c..c2b403660 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -662,24 +662,26 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1389,6 +1391,30 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1636,6 +1662,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "croner" version = "3.0.1" @@ -1989,6 +2027,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] @@ -2007,6 +2047,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "deranged" version = "0.5.5" @@ -2106,6 +2157,15 @@ dependencies = [ "ctutils", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2645,6 +2705,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.5" @@ -4272,10 +4338,12 @@ dependencies = [ "defmt", "jiff-core", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link 0.2.1", ] [[package]] @@ -4299,6 +4367,21 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -4647,9 +4730,9 @@ dependencies = [ [[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 = "litemap" @@ -5594,11 +5677,14 @@ dependencies = [ "chacha20poly1305", "chrono", "ciborium", + "directories", "eventsource-stream", + "fs2", "futures", "hex", "hkdf 0.12.4", "http", + "jiff", "p256", "percent-encoding", "pin-project", @@ -5607,6 +5693,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sigstore-tuf", + "sigstore-verify", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", @@ -6964,7 +7053,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -7125,9 +7214,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", @@ -7166,9 +7255,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -7210,7 +7299,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7243,6 +7332,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safe_arch" version = "1.1.0" @@ -7480,6 +7575,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -7701,6 +7807,183 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "sigstore-bundle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5948e95f63e900fa92936ecf72c7f2251f83d27560a35a4aae560cc745f8b687" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-crypto" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f27364b37bec104a904f10a675c8cdf05d5e48a980569368f0cd0c119b2652" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "pem", + "rand_core 0.9.5", + "sha2 0.10.9", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.18", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee85fd9fb550efd7c8a59447cb0ef4eb02f1258f3ffa80c88d38427c3930af3" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-rekor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97c5a866f849a9445ae657bef0caa2db053a82827e6dae3a2b3de9c15a6a1a" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest 0.13.2", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389b54d1b8ace20ba86fdf90e5e655495f65f1abbc7d5d0eb7494defa9ad32f0" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58fe92d4d8cf4b7215b927b70bc1245b6e0d70d801febdfe0cd265ad97ebf8b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "cmpv2", + "cms", + "const-oid 0.9.6", + "der", + "hex", + "jiff", + "rand 0.9.4", + "reqwest 0.13.2", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedac50883a917b7b434db22e2e6e853ace8c00f4a9c27f53e1e9c87e6d89fe4" +dependencies = [ + "globset", + "hex", + "jiff", + "serde", + "serde_json", + "sha2 0.10.9", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "sigstore-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236474c535a3157839926a0ae18f9c0c22b151e49634da6e5b18ad7cac8a3b69" +dependencies = [ + "base64 0.22.1", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-verify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f71aad0e1c5925d29ae2024f55f0f8898a7ad450c93668f99086624c421e0" +dependencies = [ + "base64 0.22.1", + "cms", + "const-oid 0.9.6", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simba" version = "0.10.0" @@ -8815,12 +9098,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -9008,6 +9291,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "tokio" version = "1.52.3" @@ -9722,6 +10026,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -10870,6 +11180,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1 0.10.6", + "signature", + "spki", + "tls_codec", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -10887,6 +11211,17 @@ dependencies = [ "time", ] +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index d7bb8bcd0..8adad6223 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,6 +14,15 @@ rust-version = "1.94.1" name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +default = [] +# Explicit local-development capability. Normal debug, release, CI, mobile, +# Docker, and embedded proxy builds do not enable the mock attestation bypass. +insecure-local-mock-attestation = [ + "opensecret/mock-attestation", + "maple-proxy/insecure-local-mock-attestation", +] + [build-dependencies] tauri-build = { version = "2.6.2", features = [] } plist = "1" @@ -63,8 +72,8 @@ ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", " # history. goose = { git = "https://github.com/aaif-goose/goose.git", rev = "f9c7aaccde4834810dfd13d5efa8f0d39ba28a20", package = "goose", default-features = false } goose-providers = { git = "https://github.com/aaif-goose/goose.git", rev = "f9c7aaccde4834810dfd13d5efa8f0d39ba28a20", package = "goose-providers", default-features = false } -maple-proxy = { version = "0.3.3", path = "../../proxy" } -opensecret = { version = "3.6.2", path = "../../sdk/rust" } +maple-proxy = { version = "0.3.3", path = "../../proxy", default-features = false } +opensecret = { version = "3.6.2", path = "../../sdk/rust", default-features = false } axum = "0.8" tower-http = { version = "0.6", features = ["cors"] } rand = "0.8.6" diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 397a6dfdb..25f873373 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -1,6 +1,4 @@ fn main() { - println!("cargo:rerun-if-env-changed=VITE_OPEN_SECRET_PCR_ENVIRONMENT"); - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "ios" { println!("cargo:rustc-link-lib=c++"); diff --git a/frontend/src-tauri/src/agent/provider.rs b/frontend/src-tauri/src/agent/provider.rs index 2824f2e89..9fbe956b6 100644 --- a/frontend/src-tauri/src/agent/provider.rs +++ b/frontend/src-tauri/src/agent/provider.rs @@ -49,7 +49,10 @@ const MAX_ERROR_BODY_BYTES: usize = 16 * 1024; const MAX_STREAM_LINE_BYTES: usize = 16 * 1024 * 1024; const MAX_RETRY_AFTER_SECS: f64 = 3_600.0; #[cfg(not(test))] -const RESPONSE_START_TIMEOUT: Duration = Duration::from_secs(300); +// A missing or stale encrypted session can make this call perform the same +// bounded TUF root-rotation refresh as initial credential validation before it +// reaches inference. This remains an outer UX cap, not an SDK-wide deadline. +const RESPONSE_START_TIMEOUT: Duration = Duration::from_secs(15 * 60); #[cfg(test)] const RESPONSE_START_TIMEOUT: Duration = Duration::from_millis(100); #[cfg(not(test))] @@ -916,7 +919,22 @@ fn map_opensecret_error_kind(error: opensecret::Error) -> ProviderError { ProviderError::NetworkError("The Maple network request failed".to_string()) } } - opensecret::Error::AttestationVerificationFailed(_) => { + opensecret::Error::TrustedReleaseNetwork(_) => ProviderError::NetworkError( + "Maple could not refresh its enclave trust policy".to_string(), + ), + opensecret::Error::InferenceTimeout { + phase: opensecret::InferenceTimeoutPhase::Ordinary, + .. + } => ProviderError::ExecutionError("The Maple inference request timed out".to_string()), + opensecret::Error::InferenceTimeout { + phase: opensecret::InferenceTimeoutPhase::Recovery, + .. + } => ProviderError::NetworkError( + "Maple could not recover the secure inference session in time".to_string(), + ), + opensecret::Error::AttestationVerificationFailed(_) + | opensecret::Error::UnreleasedAttestationPolicy { .. } + | opensecret::Error::TrustedReleasePolicy(_) => { ProviderError::ExecutionError(ATTESTATION_VERIFICATION_ERROR_MESSAGE.to_string()) } opensecret::Error::Session(_) @@ -966,10 +984,14 @@ fn secure_connection_stream_error(error: &anyhow::Error) -> Option &'static str { match error { opensecret::Error::Http(_) => "http", + opensecret::Error::TrustedReleaseNetwork(_) => "attestation_network", + opensecret::Error::InferenceTimeout { .. } => "inference_timeout", opensecret::Error::Serialization(_) => "serialization", opensecret::Error::Cbor(_) => "cbor", opensecret::Error::Crypto(_) => "crypto", - opensecret::Error::AttestationVerificationFailed(_) => "attestation", + opensecret::Error::AttestationVerificationFailed(_) + | opensecret::Error::UnreleasedAttestationPolicy { .. } + | opensecret::Error::TrustedReleasePolicy(_) => "attestation", opensecret::Error::Session(_) => "session", opensecret::Error::KeyExchange(_) => "key_exchange", opensecret::Error::Encryption(_) => "encryption", @@ -2247,6 +2269,74 @@ mod tests { assert_eq!(transport.remaining_response_count(), 1); } + #[test] + fn trusted_release_policy_errors_are_redacted_attestation_failures() { + let errors = [ + opensecret::Error::UnreleasedAttestationPolicy { + environment: "private-environment-detail".to_string(), + }, + opensecret::Error::TrustedReleasePolicy("private-trusted-release-detail".to_string()), + ]; + + for error in errors { + assert_eq!(opensecret_error_category(&error), "attestation"); + let mapped = map_opensecret_error(error); + assert_eq!( + mapped, + ProviderError::ExecutionError(ATTESTATION_VERIFICATION_ERROR_MESSAGE.to_string()) + ); + assert!(!mapped.to_string().contains("private")); + } + } + + #[test] + fn trusted_release_network_errors_are_redacted_and_retryable() { + let private_detail = "private-attestation-origin.example"; + let error = opensecret::Error::TrustedReleaseNetwork(private_detail.to_string()); + + assert_eq!(opensecret_error_category(&error), "attestation_network"); + let mapped = map_opensecret_error(error); + assert_eq!( + mapped, + ProviderError::NetworkError( + "Maple could not refresh its enclave trust policy".to_string() + ) + ); + assert!(!mapped.to_string().contains(private_detail)); + assert!(should_retry(&mapped, &fast_retry_config(1))); + } + + #[test] + fn inference_timeout_errors_are_redacted_with_phase_safe_retry_semantics() { + let ordinary = opensecret::Error::InferenceTimeout { + phase: opensecret::InferenceTimeoutPhase::Ordinary, + timeout_secs: 987, + }; + assert_eq!(opensecret_error_category(&ordinary), "inference_timeout"); + let mapped = map_opensecret_error(ordinary); + assert_eq!( + mapped, + ProviderError::ExecutionError("The Maple inference request timed out".to_string()) + ); + assert!(!mapped.to_string().contains("987")); + assert!(!should_retry(&mapped, &fast_retry_config(1))); + + let recovery = opensecret::Error::InferenceTimeout { + phase: opensecret::InferenceTimeoutPhase::Recovery, + timeout_secs: 987, + }; + assert_eq!(opensecret_error_category(&recovery), "inference_timeout"); + let mapped = map_opensecret_error(recovery); + assert_eq!( + mapped, + ProviderError::NetworkError( + "Maple could not recover the secure inference session in time".to_string() + ) + ); + assert!(!mapped.to_string().contains("987")); + assert!(should_retry(&mapped, &fast_retry_config(1))); + } + #[tokio::test] async fn terminal_attestation_failure_is_one_send_and_is_latched_for_the_run() { let transport = Arc::new(FakeTransport::with_results(vec![ diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 19d9a54e6..b1e529342 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -14,8 +14,6 @@ mod legacy_tts_cleanup; #[cfg(desktop)] mod maple_api; mod onnxruntime; -#[cfg(desktop)] -mod open_secret_config; mod pdf_extractor; mod pdf_ocr; #[cfg(desktop)] diff --git a/frontend/src-tauri/src/maple_api.rs b/frontend/src-tauri/src/maple_api.rs index 35a2429b8..0295799f1 100644 --- a/frontend/src-tauri/src/maple_api.rs +++ b/frontend/src-tauri/src/maple_api.rs @@ -1,4 +1,3 @@ -use crate::open_secret_config::configured_pcr0_environment; use opensecret::{ InferenceRequest, InferenceResponse, OpenSecretClient, WebExtractRequest, WebExtractResponse, WebSearchRequest, WebSearchResponse, @@ -12,7 +11,10 @@ use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; use tokio_util::sync::CancellationToken; const AUTH_CHANGED_EVENT: &str = "maple-api-auth-changed"; -const CREDENTIAL_VALIDATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +// Initial validation may need to traverse up to 32 bounded TUF root updates +// before it can perform enclave attestation and fetch the account. This is an +// outer UX cap; the SDK owns the tighter per-request trust-fetch deadlines. +const CREDENTIAL_VALIDATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -505,11 +507,7 @@ fn build_client( return Err("Maple API access token is missing".to_string()); } let refresh_token = refresh_token.filter(|token| !token.trim().is_empty()); - let client = OpenSecretClient::new_with_pcr0_environment( - api_url.to_string(), - configured_pcr0_environment()?, - ) - .map_err(map_sdk_error)?; + let client = OpenSecretClient::new(api_url.to_string()).map_err(map_sdk_error)?; client .set_tokens(access_token, refresh_token) .map_err(map_sdk_error)?; @@ -685,6 +683,7 @@ pub async fn maple_api_clear_auth( #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "insecure-local-mock-attestation")] use axum::{ extract::{Path, State}, http::{header::AUTHORIZATION, HeaderMap, StatusCode}, @@ -692,9 +691,13 @@ mod tests { routing::{get, post}, Json, Router, }; + #[cfg(feature = "insecure-local-mock-attestation")] use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + #[cfg(feature = "insecure-local-mock-attestation")] use ciborium::value::Value as CborValue; + #[cfg(feature = "insecure-local-mock-attestation")] use goose_providers::{base::Provider, conversation::message::Message, model::ModelConfig}; + #[cfg(feature = "insecure-local-mock-attestation")] use opensecret::types::KeyExchangeRequest; use std::sync::Mutex as StdMutex; use tokio::sync::Notify; @@ -748,6 +751,7 @@ mod tests { release: Arc, } + #[cfg(feature = "insecure-local-mock-attestation")] #[derive(Clone)] struct RefreshThenStallState { key_pair: Arc, @@ -756,6 +760,7 @@ mod tests { retry_started: Arc, } + #[cfg(feature = "insecure-local-mock-attestation")] struct RefreshThenStallFixture { session: Arc, sink: Arc, @@ -763,6 +768,7 @@ mod tests { server: tokio::task::JoinHandle<()>, } + #[cfg(feature = "insecure-local-mock-attestation")] fn mock_attestation_document(nonce: &str, server_public_key: &[u8; 32]) -> String { let payload = CborValue::Map(vec![ ( @@ -787,6 +793,7 @@ mod tests { BASE64.encode(cose_bytes) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn attestation_handler( State(state): State, Path(nonce): Path, @@ -799,6 +806,7 @@ mod tests { })) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn key_exchange_handler( State(state): State, Json(request): Json, @@ -818,6 +826,7 @@ mod tests { })) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn refresh_handler( State(state): State, ) -> Json { @@ -830,6 +839,7 @@ mod tests { Json(serde_json::json!({ "encrypted": BASE64.encode(encrypted) })) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn refresh_then_stall_handler( State(state): State, headers: HeaderMap, @@ -849,6 +859,7 @@ mod tests { } } + #[cfg(feature = "insecure-local-mock-attestation")] async fn refresh_then_stall_fixture() -> RefreshThenStallFixture { let key_pair = Arc::new(opensecret::crypto::generate_key_pair()); let retry_started = Arc::new(Notify::new()); @@ -897,6 +908,7 @@ mod tests { } } + #[cfg(feature = "insecure-local-mock-attestation")] async fn assert_refresh_reconciled(fixture: &RefreshThenStallFixture) { let snapshot = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { @@ -1084,6 +1096,7 @@ mod tests { assert_eq!(after.refresh_token, before.refresh_token); } + #[cfg(feature = "insecure-local-mock-attestation")] #[tokio::test] async fn provider_cancellation_after_sdk_refresh_reconciles_rotated_credentials() { let fixture = refresh_then_stall_fixture().await; @@ -1123,6 +1136,7 @@ mod tests { fixture.server.abort(); } + #[cfg(feature = "insecure-local-mock-attestation")] #[tokio::test] async fn dropped_web_call_after_sdk_refresh_still_reconciles_rotated_credentials() { let fixture = refresh_then_stall_fixture().await; @@ -1148,6 +1162,7 @@ mod tests { fixture.server.abort(); } + #[cfg(feature = "insecure-local-mock-attestation")] #[tokio::test] async fn dropped_classifier_provider_future_after_refresh_still_reconciles_credentials() { let fixture = refresh_then_stall_fixture().await; diff --git a/frontend/src-tauri/src/open_secret_config.rs b/frontend/src-tauri/src/open_secret_config.rs deleted file mode 100644 index affdb765f..000000000 --- a/frontend/src-tauri/src/open_secret_config.rs +++ /dev/null @@ -1,39 +0,0 @@ -use maple_proxy::Pcr0Environment; - -pub(crate) fn parse_pcr0_environment(value: Option<&str>) -> Result { - match value { - None | Some("production") => Ok(Pcr0Environment::Production), - Some("development") => Ok(Pcr0Environment::Development), - Some(_) => Err( - "VITE_OPEN_SECRET_PCR_ENVIRONMENT must be either production or development".to_string(), - ), - } -} - -pub(crate) fn configured_pcr0_environment() -> Result { - parse_pcr0_environment(option_env!("VITE_OPEN_SECRET_PCR_ENVIRONMENT")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pcr_environment_defaults_to_production_and_requires_an_exact_override() { - assert_eq!( - parse_pcr0_environment(None).unwrap(), - Pcr0Environment::Production - ); - assert_eq!( - parse_pcr0_environment(Some("production")).unwrap(), - Pcr0Environment::Production - ); - assert_eq!( - parse_pcr0_environment(Some("development")).unwrap(), - Pcr0Environment::Development - ); - for invalid in ["", "dev", "prod", "Development", "staging"] { - assert!(parse_pcr0_environment(Some(invalid)).is_err()); - } - } -} diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index 6b83413dc..744240d93 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -1,4 +1,3 @@ -use crate::open_secret_config::configured_pcr0_environment; use anyhow::{anyhow, Result}; use axum::{ body::Body, @@ -261,7 +260,6 @@ async fn start_proxy_inner( fn build_proxy_server_config(config: &ProxyConfig, backend_url: String) -> Result { let proxy_config = Config::new(config.host.clone(), config.port, backend_url) - .with_pcr0_environment(configured_pcr0_environment()?) .with_debug(false) // Maple owns the browser boundary below so it can both list the // non-wildcard Authorization header and reject browser origins when @@ -464,10 +462,6 @@ mod tests { assert!(!server_config.enable_cors); assert!(server_config.default_api_key.is_none()); - assert_eq!( - server_config.pcr0_environment, - configured_pcr0_environment().unwrap() - ); } #[test] diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index e755d5672..9815d009f 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -57,7 +57,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' https://opensecret.cloud https://*.opensecret.cloud https://trymaple.ai https://*.trymaple.ai https://secretgpt.ai https://*.secretgpt.ai https://*.maple-ca8.pages.dev https://raw.githubusercontent.com localhost:* http://localhost:* http://0.0.0.0:* http://127.0.0.1:*; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob: mediastream:" + "csp": "default-src 'self'; connect-src 'self' https://opensecret.cloud https://*.opensecret.cloud https://trymaple.ai https://*.trymaple.ai https://secretgpt.ai https://*.secretgpt.ai https://*.maple-ca8.pages.dev localhost:* http://localhost:* http://0.0.0.0:* http://127.0.0.1:* http://[::1]:*; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob: mediastream:" } }, "bundle": { diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2f4538c6e..d883604e2 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -17,7 +17,7 @@ import { ThemeProvider } from "./contexts/ThemeContext"; import { ProxyEventListener } from "./components/ProxyEventListener"; import { UpdateEventListener } from "./components/UpdateEventListener"; import { TTSProvider } from "./services/tts/TTSContext"; -import { openSecretPcrEnvironment } from "./config/openSecretPcrEnvironment"; +import { OPEN_SECRET_ATTESTATION_ENVIRONMENT } from "./config/attestation"; const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"; @@ -62,44 +62,7 @@ export default function App() { apiUrl={import.meta.env.VITE_OPEN_SECRET_API_URL} clientId={import.meta.env.VITE_CLIENT_ID || DEFAULT_OPEN_SECRET_CLIENT_ID} pcrConfig={{ - environment: openSecretPcrEnvironment(), - pcr0Values: [ - "ed9109c16f30a470cf0ea2251816789b4ffa510c990118323ce94a2364b9bf05bdb8777959cbac86f5cabc4852e0da71", - "4f2bcdf16c38842e1a45defd944d24ea58bb5bcb76491843223022acfe9eb6f1ff79b2cb9a6b2a9219daf9c7bf40fa37", - "b8ee4b511ef2c9c6ab3e5c0840c5df2218fbb4d9df88254ece7af9462677e55aa5a03838f3ae432d86ca1cb6f992eee7", - "33ffe5cae0f72cfe904bde8019ad98efa0ce5db2800f37c5d4149461023d1f70ea77e4f58ae1327ff46ed6a34045d6e2", - "a1398fa2946b6ed4b96a1a992ee668aef3661329690f87d44cad5b646ce33e3b16a55674b1d6d54d115a5520801b97d6", - "878dc4111e94722f3d33b202dc1368916af2eb486e74b3d94c9dfbcb3d981fa652827ea8e951ddfe06d1cefb482e431c", - "4e242871fecc14933c889908a6a7593de574c2655a47ffa163c5fd7ba41d063152ef441bd555ac7f8569eac4fd7cbc8b", - "095d38ba5c9c7ad1cfe5832d3dd8304b020392867aeef84f47e08b4305b867540b0ff5b2eb7d279de410e19ad937896e", - "72c9a1dc207d919196c78f845c0f5fd4b3b3a690e024a3dd599f160be04875fbe52983773909a9f1584105f7d5103538", - "a5ae21e211fa709dbced7fec7fff0eb86001174365a29271c07d7fb55fd9f37c7e2ddef1b01f39e977ca246277efeef2", - "2520e5067830a34fc457b6360358e3754d53ab855f4a05a81312f2a2ed0bf893c5ec43d1325972efbe3a8f2b79303734", - "a275a3877972c670c4f43e658cc9296838f79a96a26429877f74285c3a088d426583d3e2f6f99cfd96c70fdfa1475266", - "8de5541089649e9edb2cd96fafb90716aa298483447e459708e8840b1f82a557c9d9ff6ae1fd2461b04310e7d9400d7d", - "02a41da2df084fd1dee420d7717bef6dc0120f1d6a0b7fded3f4c7a539be4044b3061c71bc7156731db1fb66494097b0", - "d9638aebacf2bf15ef0ab7d394320a3aa5ebde9f0e8911d2d2a0b49a2792f3825e6f6ca56960a63e91857398125d8038", - "41786ecb8e012b910cd095ad5f8b5acefcaf80df3cf8e909499da45dd594c7c4c28302b5dde551d870555bd389a1e2c4" - ], - pcr0DevValues: [ - "799600ba64a29e360b1651f4ced6c9ca5323094a45294551327b996062c3f21e6fef651e7e3d97ec8d25be87b9935b4f", - "2fd9d4f716fd28336d96bc1a20b18a727c2d18f292577ba99323acfc8fb08959428a123b7acff478994c4f961247a0c7", - "4292db2a90ce5ea6f6e2766e0238a328c81dc060a1f3175bced2e94a10e0490d3ff9125d774dafdff969ac661778e757", - "f58409ae1bc8600c887fef5cc4055149c88c94b41c2b3e268826af7b43a1cdbacffdb2c96bf5972120c6460ab83fe89e", - "6fcdb8086806a96c421c08eaf67cebf164aa898798b6f91b072c884773bc6ed64fe8f5af644fe35411195167b0e4a5f1", - "0042958bde1fdd1bcbd4085ec94456c49e7bc5d2c3368f6f34edd6f339193cb7b53929d299eaf6a220ed5b7691f8618a", - "583ac140e0454dd4766a07c147cb6d90d5430d6bc9c1571da19c781dea4027e1c434273caba584440180ca42c2db84d5", - "4451e47ddb4be8a63492e62bc400e69d924188040805c658334f708e8682d308af3feb16018e98a5589c345d28437a6b", - "5bc5a32791948dc7e315d01ec787307799bb6f70903d14c20dc47f19bb0ef3830eb3b2c5b04b7ae5b04717046b357a14", - "4243170eeb11d38cf9bbee48b754bccfe97385b4639051efe97cda50086784cd32009dcb89a0fb1098558f22dc55b4e6", - "be3de8fa74f42cc5165823da63aa283f1c8dfedb5e27e0bfa281c6dbd12d5b5bfd9bef591200175c44dbf0c504f5b0a5", - "ab7e90e1894f75fbd423f3d0027973b611beff7402bd224dcd0f162968fb9678f08c92970d16a444edc934aa6ecd7d62", - "8ba003e9d552d262cfd40a77eced199cfd0e776d1fd69d5ed6b7e6c6c92b91a0481e965247502f95a20478fe0a8f3de7", - "2e6e8f86a657f6daed8e699c0b74bf02d6c7d6414638b1032e6addd2bca7d208a0e8aa4961eb8e926c54dc58fd63d401", - "6b15f0571de13a6357e646bbe3772a8fe32fdd85b07ba97b3a6f95bdc43023dd9deb5710c26b75346de90157d9ecdd1f", - "2596e528703abda188de27b6995f8d3cc553502e4acbb4d06db1dc8239b428638447adff38ccce358ef9fe34c2e0bccc", - "e36d72989d89818b77ab1012cf875c46c0d7fb5389fd559b9ea0950231ae1cc17dd222e5d19a84363a27a3cd7f268de6" - ] + environment: OPEN_SECRET_ATTESTATION_ENVIRONMENT }} > diff --git a/frontend/src/config/attestation.test.ts b/frontend/src/config/attestation.test.ts new file mode 100644 index 000000000..d902b1ede --- /dev/null +++ b/frontend/src/config/attestation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { resolveOpenSecretAttestationEnvironment } from "./attestation"; + +describe("resolveOpenSecretAttestationEnvironment", () => { + test("defaults to production", () => { + expect(resolveOpenSecretAttestationEnvironment(undefined)).toBe("prod"); + expect(resolveOpenSecretAttestationEnvironment("")).toBe("prod"); + expect(resolveOpenSecretAttestationEnvironment(" ")).toBe("prod"); + }); + + test("accepts only exact supported environments", () => { + expect(resolveOpenSecretAttestationEnvironment("prod")).toBe("prod"); + expect(resolveOpenSecretAttestationEnvironment(" dev ")).toBe("dev"); + }); + + test("rejects misspelled or unexpected policies", () => { + expect(() => resolveOpenSecretAttestationEnvironment("production")).toThrow( + 'VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT must be "prod" or "dev"' + ); + expect(() => resolveOpenSecretAttestationEnvironment("DEV")).toThrow( + 'VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT must be "prod" or "dev"' + ); + }); +}); diff --git a/frontend/src/config/attestation.ts b/frontend/src/config/attestation.ts new file mode 100644 index 000000000..c6efd23de --- /dev/null +++ b/frontend/src/config/attestation.ts @@ -0,0 +1,23 @@ +export type OpenSecretAttestationEnvironment = "prod" | "dev"; + +export function resolveOpenSecretAttestationEnvironment( + configuredEnvironment: string | undefined +): OpenSecretAttestationEnvironment { + const environment = configuredEnvironment?.trim(); + + if (!environment || environment === "prod") { + return "prod"; + } + + if (environment === "dev") { + return "dev"; + } + + throw new Error( + `VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT must be "prod" or "dev", received ${JSON.stringify(environment)}` + ); +} + +export const OPEN_SECRET_ATTESTATION_ENVIRONMENT = resolveOpenSecretAttestationEnvironment( + import.meta.env.VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT +); diff --git a/frontend/src/config/openSecretPcrEnvironment.test.ts b/frontend/src/config/openSecretPcrEnvironment.test.ts deleted file mode 100644 index 918c1f93e..000000000 --- a/frontend/src/config/openSecretPcrEnvironment.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { parseOpenSecretPcrEnvironment } from "./openSecretPcrEnvironment"; - -describe("OpenSecret PCR environment", () => { - test("defaults to production", () => { - expect(parseOpenSecretPcrEnvironment(undefined)).toBe("production"); - }); - - test("accepts the explicit production and development values", () => { - expect(parseOpenSecretPcrEnvironment("production")).toBe("production"); - expect(parseOpenSecretPcrEnvironment("development")).toBe("development"); - }); - - test("rejects unknown values", () => { - for (const value of ["", "dev", "prod", "Development", "staging"]) { - expect(() => parseOpenSecretPcrEnvironment(value)).toThrow( - /VITE_OPEN_SECRET_PCR_ENVIRONMENT/ - ); - } - }); -}); diff --git a/frontend/src/config/openSecretPcrEnvironment.ts b/frontend/src/config/openSecretPcrEnvironment.ts deleted file mode 100644 index c1e1d8cfd..000000000 --- a/frontend/src/config/openSecretPcrEnvironment.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PcrEnvironment } from "@opensecret/react"; - -export function parseOpenSecretPcrEnvironment(value: string | undefined): PcrEnvironment { - if (value === undefined || value === "production") return "production"; - if (value === "development") return "development"; - throw new Error('VITE_OPEN_SECRET_PCR_ENVIRONMENT must be either "production" or "development"'); -} - -export function openSecretPcrEnvironment(): PcrEnvironment { - return parseOpenSecretPcrEnvironment(import.meta.env.VITE_OPEN_SECRET_PCR_ENVIRONMENT); -} diff --git a/frontend/src/routes/proof.tsx b/frontend/src/routes/proof.tsx index aaf4aa3a6..6081d2105 100644 --- a/frontend/src/routes/proof.tsx +++ b/frontend/src/routes/proof.tsx @@ -54,25 +54,57 @@ function ProofDisplay({ parsedDocument: ParsedAttestationView; os: ReturnType; }) { + const releaseValidation = parsedDocument.validatedPcrs; + return (
-

Server PCR0 Fingerprint

+

Server Enclave Measurements

- {parsedDocument.pcrs.map( - (pcr) => - pcr.id === 0 && ( -
-

- {pcr.value} -

+

+ The OpenSecret SDK authenticates the live AWS Nitro document and requires its exact + PCR0/PCR1/PCR2 tuple to appear in the current, TUF-authenticated release policy before + accepting the enclave key. +

- -
- ) + {parsedDocument.pcrs + .filter((pcr) => pcr.id >= 0 && pcr.id <= 2) + .map((pcr) => ( +
+

PCR{pcr.id}

+

+ {pcr.value} +

+
+ ))} + + + + {releaseValidation.isMatch && ( +
+

+ Authorized release:{" "} + + {releaseValidation.releaseTag} ({releaseValidation.environment}) + +

+ {releaseValidation.sourceCommit && ( +

+ Source commit:{" "} + {releaseValidation.sourceCommit} +

+ )} +

+ TUF policy ID:{" "} + {releaseValidation.snapshotId} +

+ {releaseValidation.transparencyLog && ( +

+ Rekor log index:{" "} + {releaseValidation.transparencyLog.logIndex} +

+ )} +
)}

For technical details, check out the{" "} @@ -114,7 +146,7 @@ function ProofDisplay({

)} @@ -126,7 +158,7 @@ function ProofDisplay({
{parsedDocument.pcrs.map( (pcr) => - pcr.id !== 0 && ( + pcr.id > 2 && (

PCR{pcr.id}: {pcr.value} @@ -207,8 +239,8 @@ const DATA_FLOW_STEPS = [ }, { icon: Code, - title: "Open Source", - description: "Verifiable code with reproducible builds" + title: "Auditable Release", + description: "Measured code authorized by a signed, transparent release record" } ]; @@ -493,9 +525,9 @@ function ProofFAQ() {

Similar concept, different implementation. Apple's Private Cloud Compute uses - custom silicon with Secure Enclave. Maple uses AWS Nitro Enclaves with - attestation-verified code. Both approaches use hardware isolation to ensure that even - the service operator cannot access user data during processing. + custom silicon with Secure Enclave. Maple uses AWS Nitro Enclaves with cryptographically + attested measurements. Both approaches use hardware isolation to ensure that even the + service operator cannot access user data during processing.

@@ -516,19 +548,23 @@ function ProofFAQ() { Who do I actually have to trust?
-

Your trust assumptions are minimal and verifiable:

+

The main trust assumptions are explicit:

  • Hardware: AWS Nitro hardware performs as documented (independently audited)
  • - Code: The open-source code running in the enclave does what it says - (you can audit it) + Client: The Maple build and its pinned OpenSecret SDK correctly + enforce attestation before key exchange +
  • +
  • + Release authorization: The SDK's embedded TUF root, protected + promotion workflow, and online TUF signing key are controlled as documented
  • - Attestation: The cryptographic proof on this page confirms the - running code matches the published source + Code: The authorized open-source enclave code does what it claims + (you can audit and rebuild it)
@@ -548,10 +584,13 @@ function ProofFAQ() { > server code is open source - . The attestation document on this page is fetched live from our enclave and verified - against AWS's root certificate. You can independently reproduce the build, compare - the PCR0 hash, and confirm that the code running in production matches the published - source. + . The document on this page is fetched live and authenticated against AWS's Nitro + root. The SDK then fetches current policy from attestations.trymaple.ai, verifies its + TUF chain from the embedded root, and checks the full PCR0/PCR1/PCR2 tuple against one + active manifest. The protected promotion verifies that manifest's Cosign and Rekor + evidence; browser and Rust SDK clients then reverify the portable bundle locally. You + can rebuild the source to compare its measurements. Sigstore does not by itself prove + reproducibility or that an authorized release is current.

@@ -628,8 +667,8 @@ function Verify() { > server {" "} - code are public. Reproducible builds and attestation let you confirm what's - actually running. + code are public. Live Nitro attestation connects the enclave measurements to an + authorized tagged release; rebuilding provides a separate reproducibility check.

@@ -643,7 +682,8 @@ function Verify() {

- This attestation document is fetched live from our enclave. Verify it yourself. + This AWS-signed document is fetched live. The SDK authenticates it and authorizes all + three release measurements before key exchange.

@@ -725,7 +765,7 @@ function Verify() { "Data encrypted on your device before it leaves", "Decrypted only inside a hardware-isolated enclave (TEE)", "Your data cannot be used for model training, advertising, or tracking", - "Open-source code + live attestation so you can verify" + "AWS-signed attestation + transparent tagged-release authorization" ]} highlight /> @@ -751,19 +791,19 @@ function Verify() { /> &2; exit 1; } cd frontend - src-tauri/scripts/run-with-desktop-onnxruntime.sh bun tauri build --debug --no-sign --config ../.local/tauri-workspace.json --config '{"bundle":{"createUpdaterArtifacts":false}}' + src-tauri/scripts/run-with-desktop-onnxruntime.sh bun tauri build --debug --features insecure-local-mock-attestation --no-sign --config ../.local/tauri-workspace.json --config '{"bundle":{"createUpdaterArtifacts":false}}' # Build Tauri desktop release (with CC unset for compatibility) desktop-build-no-cc: _verify-rust-lock diff --git a/proxy/.env.example b/proxy/.env.example index 4dededced..f05f91fb2 100644 --- a/proxy/.env.example +++ b/proxy/.env.example @@ -8,13 +8,10 @@ MAPLE_PORT=8080 # Maple Backend Configuration # Production: https://enclave.trymaple.ai # Development: https://enclave.secretgpt.ai -# Local: http://localhost:3000 +# Local: use `just run-local`; plain HTTP mock attestation is compile-time +# gated and is never enabled in release, Docker, or embedded Maple builds. MAPLE_BACKEND_URL=https://enclave.trymaple.ai -# PCR0 trust roots must match the selected backend environment. -# Use development only with the development enclave; production is the default. -MAPLE_PCR0_ENVIRONMENT=production - # Authentication # Your Maple API key - get this from https://trymaple.ai MAPLE_API_KEY=your-maple-api-key-here @@ -27,7 +24,7 @@ MAPLE_DEBUG=false MAPLE_ENABLE_CORS=true # Timeouts -# Backend request setup and non-streaming response timeout, in seconds +# Per-attempt response-start and buffered non-streaming timeout, in seconds MAPLE_REQUEST_TIMEOUT_SECS=300 # Maximum idle time between streaming chunks, in seconds MAPLE_STREAM_IDLE_TIMEOUT_SECS=300 diff --git a/proxy/Cargo.lock b/proxy/Cargo.lock index de737970f..cd4a892c8 100644 --- a/proxy/Cargo.lock +++ b/proxy/Cargo.lock @@ -195,6 +195,30 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -306,6 +330,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -321,6 +351,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -346,6 +386,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -477,6 +519,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -524,6 +599,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -614,6 +701,37 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -621,6 +739,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] @@ -639,6 +759,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.8" @@ -666,6 +797,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -683,6 +835,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecdsa" version = "0.16.9" @@ -803,6 +961,12 @@ dependencies = [ "syn", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -825,6 +989,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -846,6 +1016,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -995,6 +1181,19 @@ dependencies = [ "polyval", ] +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "group" version = "0.13.0" @@ -1386,6 +1585,69 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -1416,6 +1678,21 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1648,19 +1925,25 @@ dependencies = [ "chacha20poly1305", "chrono", "ciborium", + "directories", "eventsource-stream", + "fs2", "futures", "hex", "hkdf", "http", + "jiff", "p256", "percent-encoding", "pin-project", - "reqwest", + "reqwest 0.12.28", "ring", "serde", "serde_json", "sha2", + "sigstore-tuf", + "sigstore-verify", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", @@ -1670,6 +1953,12 @@ dependencies = [ "yasna", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "p256" version = "0.13.2" @@ -1705,6 +1994,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1756,6 +2055,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "poly1305" version = "0.8.0" @@ -1779,6 +2084,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1961,7 +2281,18 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", ] [[package]] @@ -2037,6 +2368,38 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reserve-port" version = "2.4.0" @@ -2066,7 +2429,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2109,6 +2472,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.39" @@ -2139,9 +2515,10 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2156,6 +2533,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2225,6 +2608,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -2248,6 +2642,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2294,6 +2699,183 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "sigstore-bundle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5948e95f63e900fa92936ecf72c7f2251f83d27560a35a4aae560cc745f8b687" +dependencies = [ + "base64", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-crypto" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f27364b37bec104a904f10a675c8cdf05d5e48a980569368f0cd0c119b2652" +dependencies = [ + "aws-lc-rs", + "base64", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.9.5", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.18", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee85fd9fb550efd7c8a59447cb0ef4eb02f1258f3ffa80c88d38427c3930af3" +dependencies = [ + "base64", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-rekor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97c5a866f849a9445ae657bef0caa2db053a82827e6dae3a2b3de9c15a6a1a" +dependencies = [ + "base64", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389b54d1b8ace20ba86fdf90e5e655495f65f1abbc7d5d0eb7494defa9ad32f0" +dependencies = [ + "base64", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58fe92d4d8cf4b7215b927b70bc1245b6e0d70d801febdfe0cd265ad97ebf8b" +dependencies = [ + "aws-lc-rs", + "base64", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "jiff", + "rand", + "reqwest 0.13.4", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedac50883a917b7b434db22e2e6e853ace8c00f4a9c27f53e1e9c87e6d89fe4" +dependencies = [ + "globset", + "hex", + "jiff", + "serde", + "serde_json", + "sha2", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "sigstore-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236474c535a3157839926a0ae18f9c0c22b151e49634da6e5b18ad7cac8a3b69" +dependencies = [ + "base64", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-verify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f71aad0e1c5925d29ae2024f55f0f8898a7ad450c93668f99086624c421e0" +dependencies = [ + "base64", + "cms", + "const-oid", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "slab" version = "0.4.12" @@ -2381,7 +2963,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation", "system-configuration-sys", ] @@ -2396,6 +2978,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2501,6 +3096,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.52.1" @@ -2574,7 +3190,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -2725,6 +3341,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2908,7 +3530,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2943,6 +3565,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3233,7 +3877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3281,6 +3925,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -3298,6 +3956,17 @@ dependencies = [ "time", ] +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index c78dece6f..787b2e796 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -27,9 +27,15 @@ path = "src/lib.rs" name = "maple-proxy" path = "src/main.rs" +[features] +default = [] +# Explicit development-only opt-in. Release, Docker, and embedded Maple builds +# use default features and cannot accept mock attestation documents. +insecure-local-mock-attestation = ["opensecret/mock-attestation"] + [dependencies] # OpenSecret SDK -opensecret = { version = "3.6.2", path = "../sdk/rust" } +opensecret = { version = "3.6.2", path = "../sdk/rust", default-features = false } # Web server axum = { version = "0.8.4", features = ["http2", "macros"] } diff --git a/proxy/Dockerfile b/proxy/Dockerfile index ca1f45c08..24ce8a7e7 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -47,7 +47,6 @@ USER maple ENV MAPLE_HOST=0.0.0.0 \ MAPLE_PORT=8080 \ MAPLE_BACKEND_URL=https://enclave.trymaple.ai \ - MAPLE_PCR0_ENVIRONMENT=production \ MAPLE_DEBUG=false \ MAPLE_ENABLE_CORS=true \ MAPLE_REQUEST_TIMEOUT_SECS=300 \ diff --git a/proxy/README.md b/proxy/README.md index a704a9f94..efb5217aa 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -7,7 +7,8 @@ Environment (TEE) processing. ## 🚀 Features - **OpenAI-Compatible Surface** - Models, chat completions, and embeddings endpoints -- **Secure TEE Processing** - All requests processed in secure enclaves +- **Attested TEE Transport** - The OpenSecret SDK establishes an attested, + encrypted channel before inference requests are forwarded - **Lossless Chat Parameters** - Provider-specific request fields pass through unchanged - **Streaming and Non-Streaming** - Supports both chat completion response modes - **Flexible Authentication** - Environment variables or per-request API keys @@ -54,6 +55,11 @@ maple-proxy = "0.3.2" Crates.io publishing remains separate from Maple application releases; the example above uses the latest published crate version. +That published 0.3.2 crate predates the in-tree dynamic TUF/Sigstore trust +path documented below. The new behavior is currently unreleased and must ship +only after the breaking Rust SDK release, with a breaking proxy version such as +0.4.0. Do not infer the behavior below from installing 0.3.2. + ## ⚙️ Configuration Set environment variables or use command-line arguments: @@ -62,23 +68,39 @@ Set environment variables or use command-line arguments: # Environment Variables export MAPLE_HOST=127.0.0.1 # Server host (default: 127.0.0.1) export MAPLE_PORT=8080 # Server port (default: 8080) -export MAPLE_BACKEND_URL=http://localhost:3000 # Maple backend URL (prod: https://enclave.trymaple.ai) -export MAPLE_PCR0_ENVIRONMENT=production # PCR0 trust roots: production (default) or development +export MAPLE_BACKEND_URL=https://enclave.trymaple.ai # Maple backend URL export MAPLE_API_KEY=your-maple-api-key # Optional for trusted, non-browser clients only export MAPLE_DEBUG=true # Enable debug logging export MAPLE_ENABLE_CORS=false # Default; see browser warning below -export MAPLE_REQUEST_TIMEOUT_SECS=300 # Backend request timeout +export MAPLE_REQUEST_TIMEOUT_SECS=300 # Response-start/non-streaming timeout export MAPLE_STREAM_IDLE_TIMEOUT_SECS=300 # Streaming idle timeout between chunks ``` +Initial attestation has its own timeout. Within each inference call, SDK-owned +session and authentication recovery share one cumulative recovery budget. Both +use a 15-minute minimum so a cold, bounded TUF root-rotation sequence is not cut +off by a shorter inference timeout. `MAPLE_REQUEST_TIMEOUT_SECS` applies +independently to each actual inference attempt through response headers and any +buffered non-streaming body; values above 15 minutes also extend the attestation +and recovery caps. After streaming headers arrive, the separate stream idle +timeout governs each response chunk. + Or use CLI arguments: ```bash cargo run --locked -- --host 0.0.0.0 --port 8080 --backend-url https://enclave.trymaple.ai - -# Development enclaves must be selected explicitly -cargo run --locked -- --backend-url https://enclave.secretgpt.ai --pcr0-environment development ``` +For an unsigned local backend, use `just run-local`. That recipe alone enables +the explicitly named `insecure-local-mock-attestation` Cargo feature. Generic, +release, Docker, and embedded Maple builds leave the feature disabled. + +The packaged proxy automatically selects attestation policy only for the exact +official Maple/OpenSecret origins. An arbitrary remote HTTPS backend needs a +custom library integration that supplies its own `TrustedReleaseConfig` and +bootstrap root; the proxy CLI does not yet expose that trust configuration. +Unknown remote origins therefore fail closed instead of inheriting Maple +production policy. + ## 🛠️ Usage ### Using as a Binary @@ -141,7 +163,7 @@ curl http://localhost:8080/v1/embeddings \ You can also embed Maple Proxy in your own Rust application: ```rust -use maple_proxy::{Config, Pcr0Environment, create_app}; +use maple_proxy::{Config, create_app}; use tokio::net::TcpListener; #[tokio::main] @@ -155,7 +177,6 @@ async fn main() -> Result<(), Box> { 8081, // Custom port "https://enclave.trymaple.ai".to_string(), ) - .with_pcr0_environment(Pcr0Environment::Production) .with_api_key("your-api-key-here".to_string()) .with_debug(true) .with_cors(true); @@ -402,7 +423,7 @@ The Docker image: environment: - MAPLE_BACKEND_URL=https://enclave.trymaple.ai # Production backend - MAPLE_ENABLE_CORS=true # Enable for web apps - - MAPLE_REQUEST_TIMEOUT_SECS=300 # Backend request timeout + - MAPLE_REQUEST_TIMEOUT_SECS=300 # Response-start/non-streaming timeout - MAPLE_STREAM_IDLE_TIMEOUT_SECS=300 # Streaming idle timeout - RUST_LOG=info # Logging level # - MAPLE_API_KEY=xxx # Only for private deployments! @@ -497,9 +518,53 @@ cargo run --locked ``` 1. **Client** makes standard OpenAI API calls to localhost -2. **Maple Proxy** handles authentication and TEE handshake -3. **Requests** are securely forwarded to Maple's TEE infrastructure -4. **Responses** are streamed back to the client in OpenAI format +2. **Maple Proxy** handles authentication and asks the OpenSecret SDK to + establish the TEE channel +3. **OpenSecret SDK** authenticates and authorizes the enclave before accepting + its key and completing key exchange +4. **Requests** are encrypted and forwarded to Maple's TEE infrastructure +5. **Responses** are streamed back to the client in OpenAI format + +### TEE release authorization + +Sigstore/Rekor release authorization belongs in the OpenSecret SDK rather than +Maple Proxy. For each non-local backend, the SDK: + +1. refreshes signed release policy from `https://attestations.trymaple.ai/tuf`, + verifies its TUF chain and each selected portable Sigstore bundle locally; +2. creates a fresh nonce, requests the AWS Nitro attestation document, and + verifies its certificate chain, nonce, and signature; +3. extracts the complete PCR0/PCR1/PCR2 measurement tuple; +4. rechecks the held policy against current local rollback state and expiry, + then compares the tuple with one complete active release manifest; and +5. accepts the enclave public key and performs key exchange only after that + atomic tuple is authorized. + +Maple Proxy continues to call `perform_attestation_handshake`; it neither +maintains a second PCR allowlist nor implements a separate Sigstore verifier. +Keeping this policy in the SDK gives every Rust SDK consumer the same +fail-closed authorization boundary before application data is sent. + +Runtime policy requests go only to `attestations.trymaple.ai`; the SDK does not +contact GitHub, Fulcio, Rekor, or Sigstore's TUF service during a handshake. +It starts from its embedded Maple TUF root, verifies current expiring policy and +the exact immutable manifest/bundle bytes, and performs the Sigstore verification +offline with the TUF-authenticated Sigstore trusted root. Builder admission is a +publisher/promotion policy, not a proxy or SDK runtime allowlist. + +Sigstore makes a release statement and its signing identity tamper-evident in +an append-only transparency log. That identity remains useful audit evidence; +TUF authorizes the exact current manifest and bundle instead of clients pinning +a repository, workflow, CI provider, issuer, or signer identity. Sigstore does +**not** prove that an artifact was reproducibly built or decide whether a +historical release is still current. +Reproducibility remains a separate Nix rebuild/compare property; TUF supplies +current authorization, bounded freshness, explicit rollback, and revocation. + +> **Integration status:** the embedded TUF root is intentionally an unconfigured, +> fail-closed placeholder until production bootstrap is reviewed. Remote +> handshakes therefore fail closed in this draft branch; no release or policy is +> published by this change. ## 📝 License diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml index 9f772cfe3..b7e0ae8a9 100644 --- a/proxy/docker-compose.yml +++ b/proxy/docker-compose.yml @@ -19,7 +19,6 @@ services: # Backend configuration (defaults to production) - MAPLE_BACKEND_URL=${MAPLE_BACKEND_URL:-https://enclave.trymaple.ai} - - MAPLE_PCR0_ENVIRONMENT=${MAPLE_PCR0_ENVIRONMENT:-production} # Authentication - Uncomment ONLY for private/internal deployments! # For public deployments: Keep this commented out - clients will pass their own API keys diff --git a/proxy/examples/library_usage.rs b/proxy/examples/library_usage.rs index a44c94527..28ed9517e 100644 --- a/proxy/examples/library_usage.rs +++ b/proxy/examples/library_usage.rs @@ -1,4 +1,4 @@ -use maple_proxy::{create_app, Config, Pcr0Environment}; +use maple_proxy::{create_app, Config}; use tokio::net::TcpListener; #[tokio::main] @@ -12,7 +12,6 @@ async fn main() -> Result<(), Box> { 8081, // Custom port "https://enclave.trymaple.ai".to_string(), ) - .with_pcr0_environment(Pcr0Environment::Production) .with_api_key("your-api-key-here".to_string()) .with_debug(true) .with_cors(true); diff --git a/proxy/flake.nix b/proxy/flake.nix index 1ba6243cc..397b49a4e 100644 --- a/proxy/flake.nix +++ b/proxy/flake.nix @@ -38,7 +38,6 @@ pkg-config openssl zlib - gcc clang libclang @@ -53,6 +52,8 @@ ]; linuxOnlyInputs = with pkgs; [ + gcc + # Container runtime for Docker compatibility podman conmon diff --git a/proxy/justfile b/proxy/justfile index 016f3324e..d7007f62c 100644 --- a/proxy/justfile +++ b/proxy/justfile @@ -64,7 +64,8 @@ run-with-backend url: # Run pointing to local backend run-local: - @just run-with-backend "http://localhost:3000" + @echo "🚧 Starting with development-only mock attestation enabled" + @bash -c 'set -a; source .env 2>/dev/null; set +a; MAPLE_BACKEND_URL=http://localhost:3000 cargo run --locked --features insecure-local-mock-attestation' # Run pointing to production backend run-prod: @@ -161,7 +162,6 @@ env: @echo "MAPLE_HOST: ${MAPLE_HOST:-127.0.0.1}" @echo "MAPLE_PORT: ${MAPLE_PORT:-8080}" @echo "MAPLE_BACKEND_URL: ${MAPLE_BACKEND_URL:-https://enclave.trymaple.ai}" - @echo "MAPLE_PCR0_ENVIRONMENT: ${MAPLE_PCR0_ENVIRONMENT:-production}" @echo "MAPLE_API_KEY: $(if [ -n \"${MAPLE_API_KEY:-}\" ]; then printf '[set]'; else printf '[not set]'; fi)" @echo "MAPLE_DEBUG: ${MAPLE_DEBUG:-false}" @echo "MAPLE_ENABLE_CORS: ${MAPLE_ENABLE_CORS:-false}" @@ -181,7 +181,6 @@ docker-run: -p ${MAPLE_PORT:-8080}:8080 \ -e MAPLE_API_KEY=${MAPLE_API_KEY} \ -e MAPLE_BACKEND_URL=${MAPLE_BACKEND_URL:-https://enclave.trymaple.ai} \ - -e MAPLE_PCR0_ENVIRONMENT=${MAPLE_PCR0_ENVIRONMENT:-production} \ -e MAPLE_DEBUG=${MAPLE_DEBUG:-false} \ -e MAPLE_ENABLE_CORS=${MAPLE_ENABLE_CORS:-true} \ -e MAPLE_REQUEST_TIMEOUT_SECS=${MAPLE_REQUEST_TIMEOUT_SECS:-300} \ @@ -196,7 +195,6 @@ docker-run-detached: -p ${MAPLE_PORT:-8080}:8080 \ -e MAPLE_API_KEY=${MAPLE_API_KEY} \ -e MAPLE_BACKEND_URL=${MAPLE_BACKEND_URL:-https://enclave.trymaple.ai} \ - -e MAPLE_PCR0_ENVIRONMENT=${MAPLE_PCR0_ENVIRONMENT:-production} \ -e MAPLE_DEBUG=${MAPLE_DEBUG:-false} \ -e MAPLE_ENABLE_CORS=${MAPLE_ENABLE_CORS:-true} \ -e MAPLE_REQUEST_TIMEOUT_SECS=${MAPLE_REQUEST_TIMEOUT_SECS:-300} \ diff --git a/proxy/src/config.rs b/proxy/src/config.rs index ee8080a9c..5d56e2407 100644 --- a/proxy/src/config.rs +++ b/proxy/src/config.rs @@ -1,5 +1,4 @@ use clap::Parser; -use opensecret::Pcr0Environment; use serde::Serialize; use std::{net::SocketAddr, time::Duration}; @@ -19,7 +18,9 @@ pub struct Config { #[arg(short, long, env = "MAPLE_PORT", default_value = "8080")] pub port: u16, - /// OpenSecret/Maple backend URL + /// OpenSecret/Maple backend URL. The packaged binary supplies trust policy + /// for exact official origins; arbitrary remote HTTPS deployments require + /// a library integration with an explicit TrustedReleaseConfig. #[arg( long, env = "MAPLE_BACKEND_URL", @@ -27,15 +28,6 @@ pub struct Config { )] pub backend_url: String, - /// PCR0 trust-root environment for backend attestation - #[arg( - long, - env = "MAPLE_PCR0_ENVIRONMENT", - default_value = "production", - value_parser = parse_pcr0_environment - )] - pub pcr0_environment: Pcr0Environment, - /// Default API key for Maple/OpenSecret (can be overridden by client Authorization header) #[arg(long, env = "MAPLE_API_KEY")] pub default_api_key: Option, @@ -48,7 +40,10 @@ pub struct Config { #[arg(long, env = "MAPLE_ENABLE_CORS")] pub enable_cors: bool, - /// Timeout for backend request setup and non-streaming responses, in seconds + /// Per-attempt timeout through response headers and any buffered + /// non-streaming body, in seconds. Initial attestation and each inference + /// call's cumulative SDK recovery budget use at least 15 minutes so a + /// bounded TUF root-rotation sequence is not truncated by a shorter timeout. #[arg( long, env = "MAPLE_REQUEST_TIMEOUT_SECS", @@ -87,7 +82,6 @@ impl Config { host, port, backend_url, - pcr0_environment: Pcr0Environment::Production, default_api_key: None, debug: false, enable_cors: false, @@ -104,12 +98,6 @@ impl Config { Duration::from_secs(self.stream_idle_timeout_secs) } - /// Builder-style method to select the backend PCR0 trust-root environment - pub fn with_pcr0_environment(mut self, pcr0_environment: Pcr0Environment) -> Self { - self.pcr0_environment = pcr0_environment; - self - } - /// Builder-style method to set the API key pub fn with_api_key(mut self, api_key: String) -> Self { self.default_api_key = Some(api_key); @@ -128,7 +116,8 @@ impl Config { self } - /// Builder-style method to set the backend request timeout + /// Builder-style method to set the per-attempt inference timeout. Values + /// below 15 minutes do not shorten the separate attestation/recovery floor. pub fn with_request_timeout_secs(mut self, request_timeout_secs: u64) -> Self { self.request_timeout_secs = request_timeout_secs; self @@ -141,14 +130,6 @@ impl Config { } } -fn parse_pcr0_environment(value: &str) -> Result { - match value { - "production" => Ok(Pcr0Environment::Production), - "development" => Ok(Pcr0Environment::Development), - _ => Err("PCR0 environment must be 'production' or 'development'".to_string()), - } -} - #[derive(Debug, Serialize)] pub(crate) struct OpenAIError { error: OpenAIErrorDetails, @@ -188,28 +169,6 @@ impl OpenAIError { mod tests { use super::*; use clap::{error::ErrorKind, Parser}; - use std::sync::Mutex; - - static PCR0_ENVIRONMENT_LOCK: Mutex<()> = Mutex::new(()); - - fn with_pcr0_environment_env(value: Option<&str>, run: impl FnOnce() -> T) -> T { - let _guard = PCR0_ENVIRONMENT_LOCK.lock().unwrap(); - let previous = std::env::var_os("MAPLE_PCR0_ENVIRONMENT"); - - match value { - Some(value) => std::env::set_var("MAPLE_PCR0_ENVIRONMENT", value), - None => std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"), - } - - let result = run(); - - match previous { - Some(previous) => std::env::set_var("MAPLE_PCR0_ENVIRONMENT", previous), - None => std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"), - } - - result - } #[test] fn config_new_uses_timeout_defaults() { @@ -220,7 +179,6 @@ mod tests { ); assert_eq!(config.request_timeout_secs, DEFAULT_REQUEST_TIMEOUT_SECS); - assert_eq!(config.pcr0_environment, Pcr0Environment::Production); assert_eq!( config.stream_idle_timeout_secs, DEFAULT_STREAM_IDLE_TIMEOUT_SECS @@ -235,51 +193,6 @@ mod tests { ); } - #[test] - fn pcr0_environment_defaults_to_production_for_cli() { - let config = - with_pcr0_environment_env(None, || Config::try_parse_from(["maple-proxy"]).unwrap()); - - assert_eq!(config.pcr0_environment, Pcr0Environment::Production); - } - - #[test] - fn pcr0_environment_accepts_explicit_development_cli_value() { - let config = - Config::try_parse_from(["maple-proxy", "--pcr0-environment", "development"]).unwrap(); - - assert_eq!(config.pcr0_environment, Pcr0Environment::Development); - } - - #[test] - fn pcr0_environment_builder_selects_development() { - let config = Config::new( - "127.0.0.1".to_string(), - 8080, - "https://enclave.secretgpt.ai".to_string(), - ) - .with_pcr0_environment(Pcr0Environment::Development); - - assert_eq!(config.pcr0_environment, Pcr0Environment::Development); - } - - #[test] - fn pcr0_environment_accepts_explicit_development_env_value() { - let config = with_pcr0_environment_env(Some("development"), || { - Config::try_parse_from(["maple-proxy"]).unwrap() - }); - - assert_eq!(config.pcr0_environment, Pcr0Environment::Development); - } - - #[test] - fn pcr0_environment_rejects_unknown_values() { - let error = - Config::try_parse_from(["maple-proxy", "--pcr0-environment", "staging"]).unwrap_err(); - - assert_eq!(error.kind(), ErrorKind::ValueValidation); - } - #[test] fn timeout_builder_methods_override_defaults() { let config = Config::new( diff --git a/proxy/src/lib.rs b/proxy/src/lib.rs index 2650eaeba..41d3f9eca 100644 --- a/proxy/src/lib.rs +++ b/proxy/src/lib.rs @@ -2,7 +2,6 @@ mod config; mod proxy; pub use config::Config; -pub use opensecret::Pcr0Environment; use proxy::{health_check, proxy_openai_request, ProxyState}; use axum::{ diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 852d57aac..42d624a3f 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -21,7 +21,6 @@ async fn main() -> anyhow::Result<()> { info!("Starting Maple Proxy Server"); info!("Version: {}", env!("CARGO_PKG_VERSION")); info!("Backend URL: {}", config.backend_url); - info!("PCR0 environment: {:?}", config.pcr0_environment); info!("Binding to: {}", config.socket_addr()?); if config.default_api_key.is_some() { diff --git a/proxy/src/proxy.rs b/proxy/src/proxy.rs index c13b76f52..50070429c 100644 --- a/proxy/src/proxy.rs +++ b/proxy/src/proxy.rs @@ -8,7 +8,10 @@ use axum::{ }; use dashmap::DashMap; use futures::{future::BoxFuture, Stream, StreamExt}; -use opensecret::{client::OpenSecretResponseBody, OpenSecretClient, Result as OpenSecretResult}; +use opensecret::{ + client::OpenSecretResponseBody, Error as OpenSecretError, InferenceTimeoutPhase, + OpenSecretClient, Result as OpenSecretResult, +}; use std::{ collections::HashSet, io, @@ -21,6 +24,10 @@ use tracing::{debug, error}; const CLIENT_CACHE_MAX_ENTRIES: usize = 1024; const CLIENT_CACHE_ENTRY_TTL: Duration = Duration::from_secs(60 * 60); +// A cold policy refresh can traverse up to 32 individually bounded TUF root +// updates before enclave attestation. This independent floor applies to initial +// attestation and to each inference call's cumulative recovery budget. +const MINIMUM_ATTESTATION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(15 * 60); type ProxyError = (StatusCode, Json); @@ -28,6 +35,8 @@ trait InferenceTransport: Send + Sync { fn send_inference_request( &self, request: Request, + ordinary_timeout: Duration, + recovery_timeout: Duration, ) -> BoxFuture<'_, OpenSecretResult>>; } @@ -35,8 +44,15 @@ impl InferenceTransport for OpenSecretClient { fn send_inference_request( &self, request: Request, + ordinary_timeout: Duration, + recovery_timeout: Duration, ) -> BoxFuture<'_, OpenSecretResult>> { - Box::pin(OpenSecretClient::send_inference_request(self, request)) + Box::pin(OpenSecretClient::send_inference_request_with_timeouts( + self, + request, + ordinary_timeout, + recovery_timeout, + )) } } @@ -107,25 +123,16 @@ impl ProxyState { let cache_key = api_key.to_string(); let client_entry = self.client_entry_for_api_key(&cache_key); let backend_url = self.config.backend_url.clone(); - let pcr0_environment = self.config.pcr0_environment; let request_timeout = self.config.request_timeout(); let init_api_key = cache_key.clone(); let client = client_entry .cell .get_or_try_init(|| async move { - debug!( - "Creating OpenSecret client for API key: {}...", - &init_api_key[..8.min(init_api_key.len())] - ); - create_client_with_auth( - &backend_url, - &init_api_key, - pcr0_environment, - request_timeout, - ) - .await - .map(Arc::new) + debug!("Creating OpenSecret client for authenticated request"); + create_client_with_auth(&backend_url, &init_api_key, request_timeout) + .await + .map(Arc::new) }) .await; @@ -209,25 +216,26 @@ fn extract_api_key( async fn create_client_with_auth( backend_url: &str, api_key: &str, - pcr0_environment: opensecret::Pcr0Environment, request_timeout: Duration, ) -> Result { - let client = OpenSecretClient::new_with_api_key_and_pcr0_environment( - backend_url, - api_key.to_string(), - pcr0_environment, - ) - .map_err(|e| transport_error_response("OpenSecret client creation", &e))?; + let client = OpenSecretClient::new_with_api_key(backend_url, api_key.to_string()) + .map_err(|e| transport_error_response("OpenSecret client creation", &e))?; - // Perform attestation handshake - tokio::time::timeout(request_timeout, client.perform_attestation_handshake()) + // Perform attestation handshake. A configured value may extend this cap, + // but must not truncate a valid bounded TUF root-rotation sequence. + let recovery_timeout = attestation_recovery_timeout(request_timeout); + tokio::time::timeout(recovery_timeout, client.perform_attestation_handshake()) .await - .map_err(|_| timeout_response("Attestation handshake", request_timeout))? + .map_err(|_| timeout_response("Attestation handshake", recovery_timeout))? .map_err(|e| transport_error_response("OpenSecret attestation handshake", &e))?; Ok(client) } +fn attestation_recovery_timeout(request_timeout: Duration) -> Duration { + request_timeout.max(MINIMUM_ATTESTATION_RECOVERY_TIMEOUT) +} + fn timeout_response(operation: &str, timeout: Duration) -> ProxyError { error!( "{} timed out after {} seconds", @@ -256,20 +264,16 @@ pub(crate) async fn proxy_openai_request( let api_key = extract_api_key(&headers, &state.config.default_api_key) .map_err(|e| (StatusCode::UNAUTHORIZED, Json(e)))?; - debug!( - "Proxying {} {} for API key: {}...", - method, - uri, - &api_key[..8.min(api_key.len())] - ); + debug!("Proxying {} {}", method, uri); let transport = state.transport_for_api_key(&api_key).await?; let request = build_upstream_request(method, uri, &headers, body); let request_timeout = state.config.request_timeout(); - let response = tokio::time::timeout(request_timeout, transport.send_inference_request(request)) + let recovery_timeout = attestation_recovery_timeout(request_timeout); + let response = transport + .send_inference_request(request, request_timeout, recovery_timeout) .await - .map_err(|_| timeout_response("OpenAI-compatible request", request_timeout))? - .map_err(|error| transport_error_response("OpenSecret inference request", &error))?; + .map_err(|error| inference_error_response(&error))?; Ok(build_downstream_response( response, @@ -343,6 +347,22 @@ fn transport_error_response(operation: &str, error: &impl std::fmt::Display) -> ) } +fn inference_error_response(error: &OpenSecretError) -> ProxyError { + match error { + OpenSecretError::InferenceTimeout { + phase, + timeout_secs, + } => { + let operation = match phase { + InferenceTimeoutPhase::Ordinary => "OpenAI-compatible request", + InferenceTimeoutPhase::Recovery => "Inference recovery", + }; + timeout_response(operation, Duration::from_secs(*timeout_secs)) + } + _ => transport_error_response("OpenSecret inference request", error), + } +} + fn build_downstream_response( response: http::Response, stream_idle_timeout: Duration, @@ -435,7 +455,6 @@ mod tests { host: "127.0.0.1".to_string(), port: 0, backend_url: "http://localhost:3000".to_string(), - pcr0_environment: opensecret::Pcr0Environment::Production, default_api_key: None, debug: false, enable_cors: false, @@ -444,8 +463,24 @@ mod tests { } } + #[test] + fn attestation_recovery_covers_bounded_root_rotation_without_changing_inference_timeout() { + let configured = Duration::from_secs(45); + + assert_eq!( + attestation_recovery_timeout(configured), + Duration::from_secs(15 * 60) + ); + assert_eq!(configured, Duration::from_secs(45)); + assert_eq!( + attestation_recovery_timeout(Duration::from_secs(20 * 60)), + Duration::from_secs(20 * 60) + ); + } + struct MockTransport { requests: Mutex>>, + timeouts: Mutex>, responses: Mutex>>>, } @@ -453,6 +488,7 @@ mod tests { fn new(responses: Vec>>) -> Self { Self { requests: Mutex::new(Vec::new()), + timeouts: Mutex::new(Vec::new()), responses: Mutex::new(responses.into()), } } @@ -460,14 +496,24 @@ mod tests { fn take_requests(&self) -> Vec> { std::mem::take(&mut *self.requests.lock().unwrap()) } + + fn take_timeouts(&self) -> Vec<(Duration, Duration)> { + std::mem::take(&mut *self.timeouts.lock().unwrap()) + } } impl InferenceTransport for MockTransport { fn send_inference_request( &self, request: Request, + ordinary_timeout: Duration, + recovery_timeout: Duration, ) -> BoxFuture<'_, OpenSecretResult>> { self.requests.lock().unwrap().push(request); + self.timeouts + .lock() + .unwrap() + .push((ordinary_timeout, recovery_timeout)); let response = self .responses .lock() @@ -478,17 +524,6 @@ mod tests { } } - struct PendingTransport; - - impl InferenceTransport for PendingTransport { - fn send_inference_request( - &self, - _request: Request, - ) -> BoxFuture<'_, OpenSecretResult>> { - Box::pin(std::future::pending()) - } - } - fn raw_response( status: StatusCode, headers: &[(&str, &str)], @@ -756,13 +791,18 @@ mod tests { } #[tokio::test] - async fn response_start_timeout_is_gateway_timeout() { + async fn inference_timeouts_are_forwarded_to_the_sdk() { + let transport = Arc::new(MockTransport::new(vec![Ok(raw_response( + StatusCode::OK, + &[], + vec![Bytes::from_static(b"ok")], + ))])); let mut config = test_config(); config.default_api_key = Some("default-key".to_string()); - config.request_timeout_secs = 1; + config.request_timeout_secs = 45; let state = Arc::new(ProxyState::with_transport( config.clone(), - Arc::new(PendingTransport), + Arc::clone(&transport) as Arc, )); let response = crate::create_app_with_state(config, state) .oneshot( @@ -775,7 +815,51 @@ mod tests { .await .unwrap(); - assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + transport.take_timeouts(), + vec![(Duration::from_secs(45), Duration::from_secs(15 * 60))] + ); + } + + #[tokio::test] + async fn typed_inference_timeouts_are_safe_gateway_timeouts() { + for (phase, timeout_secs, expected_operation) in [ + ( + InferenceTimeoutPhase::Ordinary, + 45, + "OpenAI-compatible request", + ), + ( + InferenceTimeoutPhase::Recovery, + 15 * 60, + "Inference recovery", + ), + ] { + let transport = Arc::new(MockTransport::new(vec![Err( + OpenSecretError::InferenceTimeout { + phase, + timeout_secs, + }, + )])); + let response = mock_app(transport) + .oneshot( + AxumRequest::builder() + .method(Method::GET) + .uri("/v1/models") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let body = to_bytes(response.into_body(), 1024).await.unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains(expected_operation)); + assert!(body.contains(&timeout_secs.to_string())); + assert!(!body.contains("OpenSecret")); + } } #[tokio::test] diff --git a/scripts/ci/_common.sh b/scripts/ci/_common.sh index 84b4d81e0..b7d95499d 100755 --- a/scripts/ci/_common.sh +++ b/scripts/ci/_common.sh @@ -146,7 +146,7 @@ use_pr_environment() { done < <(env) export VITE_OPEN_SECRET_API_URL="https://enclave.secretgpt.ai" - export VITE_OPEN_SECRET_PCR_ENVIRONMENT="development" + export VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT="dev" export VITE_OS_FLAGS_BASE_URL="https://flags-dev.opensecret.cloud" export VITE_MAPLE_BILLING_API_URL="https://billing-dev.opensecret.cloud" export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6" @@ -163,7 +163,7 @@ use_release_environment() { done < <(env) export VITE_OPEN_SECRET_API_URL="https://enclave.trymaple.ai" - export VITE_OPEN_SECRET_PCR_ENVIRONMENT="production" + export VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT="prod" export VITE_OS_FLAGS_BASE_URL="https://flags.opensecret.cloud" export VITE_MAPLE_BILLING_API_URL="https://billing.opensecret.cloud" export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6" diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index 38f012abc..ab113222c 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -33,6 +33,16 @@ PURE_FRONTEND_FILES = frozenset({"frontend/icon.svg", "frontend/index.html"}) SDK_FRONTEND_PREFIXES = ("sdk/src/",) SDK_TEST_PREFIXES = ("sdk/src/lib/test/",) +SDK_ATTESTATION_RUNTIME_FILES = frozenset( + { + "sdk/src/lib/attestation-tuf-root.generated.json", + "sdk/src/lib/attestation.ts", + "sdk/src/lib/attestationForView.ts", + "sdk/src/lib/attestationTuf.ts", + "sdk/src/lib/getAttestation.ts", + "sdk/src/lib/pcr.ts", + } +) SDK_FRONTEND_FILES = frozenset( { "sdk/bun.lock", @@ -184,6 +194,11 @@ def classify_path(path: str) -> frozenset[str]: return frozenset() if path.startswith(SDK_TEST_PREFIXES): return frozenset() + if path in SDK_ATTESTATION_RUNTIME_FILES: + # These files are embedded in the WebView application. In particular, + # rotating the TUF bootstrap root must exercise packaged iOS and Android + # builds instead of only the standalone frontend lane. + return frozenset({"frontend", "ios", "android"}) if path in SDK_FRONTEND_FILES or path.startswith(SDK_FRONTEND_PREFIXES): return frozenset({"frontend"}) if path == "sdk/rust/Cargo.toml" or path.startswith(SDK_RUST_RUNTIME_PREFIXES): diff --git a/scripts/ci/rust.sh b/scripts/ci/rust.sh index 6d6565ed4..8db154af5 100755 --- a/scripts/ci/rust.sh +++ b/scripts/ci/rust.sh @@ -10,3 +10,4 @@ prepare_linux_onnxruntime cd "${TAURI_DIR}" cargo test --all-targets --locked +cargo test --lib --locked --features insecure-local-mock-attestation maple_api::tests diff --git a/scripts/ci/test_change_detection.py b/scripts/ci/test_change_detection.py index 7c7451f55..edecf082d 100644 --- a/scripts/ci/test_change_detection.py +++ b/scripts/ci/test_change_detection.py @@ -53,6 +53,18 @@ def test_typescript_sdk_inputs_mark_only_the_frontend_lane(self) -> None: self.assert_routes(["sdk/package.json"], "frontend") self.assert_routes(["sdk/bun.lock"], "frontend") + def test_sdk_attestation_runtime_rebuilds_packaged_mobile_apps(self) -> None: + for path in ( + "sdk/src/lib/attestation-tuf-root.generated.json", + "sdk/src/lib/attestation.ts", + "sdk/src/lib/attestationForView.ts", + "sdk/src/lib/attestationTuf.ts", + "sdk/src/lib/getAttestation.ts", + "sdk/src/lib/pcr.ts", + ): + with self.subTest(path=path): + self.assert_routes([path], "frontend", "ios", "android") + def test_renderer_changes_only_mark_the_frontend_lane(self) -> None: self.assert_routes(["frontend/src/routes/index.tsx"], "frontend") self.assert_routes(["frontend/public/favicon.svg"], "frontend") diff --git a/scripts/ci/verify-local-rust-deps.sh b/scripts/ci/verify-local-rust-deps.sh index 087021c28..c486d299b 100755 --- a/scripts/ci/verify-local-rust-deps.sh +++ b/scripts/ci/verify-local-rust-deps.sh @@ -3,13 +3,19 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" metadata_file="$(mktemp)" -trap 'rm -f "${metadata_file}"' EXIT +local_mock_metadata_file="$(mktemp)" +trap 'rm -f "${metadata_file}" "${local_mock_metadata_file}"' EXIT cd "${repo_root}" cargo metadata \ --locked \ --manifest-path frontend/src-tauri/Cargo.toml \ --format-version 1 > "${metadata_file}" +cargo metadata \ + --locked \ + --manifest-path frontend/src-tauri/Cargo.toml \ + --features insecure-local-mock-attestation \ + --format-version 1 > "${local_mock_metadata_file}" jq -e --arg root "${repo_root}" ' ([.packages[] | select(.name == "opensecret")] | length) == 1 and @@ -20,4 +26,22 @@ jq -e --arg root "${repo_root}" ' ([.packages[] | select(.name == "maple-proxy")][0].manifest_path == ($root + "/proxy/Cargo.toml")) ' "${metadata_file}" > /dev/null +opensecret_id="$(jq -er '[.packages[] | select(.name == "opensecret" and .source == null)][0].id' "${metadata_file}")" +proxy_id="$(jq -er '[.packages[] | select(.name == "maple-proxy" and .source == null)][0].id' "${metadata_file}")" + +jq -e \ + --arg opensecret_id "${opensecret_id}" \ + --arg proxy_id "${proxy_id}" ' + ([.resolve.nodes[] | select(.id == $opensecret_id)][0].features | index("mock-attestation")) == null and + ([.resolve.nodes[] | select(.id == $proxy_id)][0].features | index("insecure-local-mock-attestation")) == null + ' "${metadata_file}" > /dev/null + +jq -e \ + --arg opensecret_id "${opensecret_id}" \ + --arg proxy_id "${proxy_id}" ' + ([.resolve.nodes[] | select(.id == $opensecret_id)][0].features | index("mock-attestation")) != null and + ([.resolve.nodes[] | select(.id == $proxy_id)][0].features | index("insecure-local-mock-attestation")) != null + ' "${local_mock_metadata_file}" > /dev/null + echo "Maple resolves exactly one in-tree OpenSecret SDK and proxy crate." +echo "Default builds exclude mock attestation; the explicit local-development feature enables it." diff --git a/sdk/.env.example b/sdk/.env.example index 40e6df433..b78b72165 100644 --- a/sdk/.env.example +++ b/sdk/.env.example @@ -1,7 +1,7 @@ # API URL VITE_OPEN_SECRET_API_URL= -# PCR trust environment: production (default) or development -VITE_OPEN_SECRET_PCR_ENVIRONMENT=production +# Sigstore trusted-release environment: prod or dev +VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=dev # Test credentials VITE_TEST_EMAIL= diff --git a/sdk/README.md b/sdk/README.md index 664af216f..d5e422535 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -29,12 +29,143 @@ for external users. ## Security model For non-local endpoints, both SDKs require HTTPS, verify AWS Nitro attestation, -and enforce an environment-scoped PCR0 trust policy before completing key -exchange. Official PCR0 histories are signed and bundled with the SDKs. +and require the complete PCR0/PCR1/PCR2 tuple to match an environment-scoped +active release before completing key exchange. Current release authorization is +published as standard, consistent-snapshot TUF metadata and targets at the one +fixed origin `https://attestations.trymaple.ai/tuf`. The SDK embeds only the TUF +root of trust; it does not fetch GitHub PCR histories or dereference source, +Fulcio, Rekor, or GitHub provenance URLs at runtime. + +The browser flow is deliberately split at a clear verification boundary: + +```text +release promotion pipeline + admits configured builders, verifies locked release inputs and Sigstore evidence + publishes manifest + bundle, then atomically authorizes them with TUF + +browser before each new attested session + verifies TUF root rotation, signatures, versions, expiry, hashes, and lengths + loads only the selected prod or dev channel (at most two active manifests) + verifies each exact manifest byte sequence against its Fulcio certificate, + SCT, Rekor body/inclusion proof/signed checkpoint, and RFC3161 timestamp + compares PCR0 + PCR1 + PCR2 against one complete authenticated manifest + performs key exchange only after that tuple matches +``` + +The browser uses the exact-pinned `@freedomofpress/sigstore-browser` dependency +behind Maple's bounded v0.3 message-signature adapter. The adapter rejects DSSE +and legacy certificate-chain layouts, requires one Rekor entry with an inclusion +proof and signed checkpoint, requires CT/Rekor/TSA thresholds of one, and verifies +the raw manifest bytes rather than parsed or reserialized JSON. It also checks the +full Fulcio path at the authenticated RFC3161 observer time. For Rekor v2, the +absent/null/zero legacy integrated-time field is ignored and the required RFC3161 +timestamp is authoritative. TUF supplies current authorization and rollback +protection; the immutable Sigstore record supplies cryptographic provenance and +transparency. Neither is a substitute for the other. + +Builder admission remains a promotion concern. `build.builderId`, certificate +SANs, OIDC issuer, repository, workflow, and run URI are authenticated audit +provenance after TUF and Sigstore verification, but the SDK does not match them +against a client-side allowlist. This keeps authorized reproducible builders +fungible and avoids coupling existing SDKs to a repository, CI provider, or +workflow name during a migration. + +The Rust SDK has a different capability boundary: it uses the maintained +`sigstore-tuf` and `sigstore-verify` crates to verify both layers locally before +accepting an active PCR tuple. It follows the same authorization split: TUF +selects exact evidence, while Sigstore verifies that evidence cryptographically; +certificate identity is not a second client authorization policy. + +Policy targets are isolated per channel, while root, timestamp, snapshot, and +targets rollback high-water marks are shared for the repository. Browser cache +entries use the immutable v4 authority-history schema and versioned keys, so a +stale tab cannot overwrite or erase a newer generation it did not observe; Web +Locks serialize cleanup when available. Pre-root-history v3 cache entries fail +closed rather than silently resetting their trust floors. +An authenticated-observation journal persists newer root or partial metadata +floors before the next download, without activating a partial PCR policy. Before +authorizing a freshly verified Nitro document, the browser rechecks signed +metadata expiry and the already-loaded policy against that durable journal; this +is a non-network currentness check, so a cross-tab revocation cannot be hidden by +the policy object held during document verification. + +Rollback floors retain bounded cumulative full-authority provenance and replay +every authenticated root transition in sequence. A direct metadata floor resets +only when its old key material cannot meet the replacement role's threshold. A +snapshot or targets descriptor may reset when either its signing parent or its +referenced child authority is fully replaced; only targets-authority replacement +resets a channel sequence. An overlap rotation conservatively widens surviving +provenance because TUF signatures are detachable, so staged overlap is not a +compromise-recovery mechanism. Recovery requires fresh non-overlapping keys (and +may also require rotating the parent that authenticated a poisoned descriptor). +Duplicate aliases, moving retired key material between online roles, and later +reauthorizing retired timestamp, snapshot, or targets keys are rejected: release +operations must never reuse retired online-role key material. + +The v1 client contract keeps the original embedded root and follows every +numbered remote root update in order, with at most 32 rotations over the entire +embedded-root trust epoch (root v33 for the official root-v1 client). At that +ceiling, the browser probes root v34 only as a non-persisted sentinel and fails +on every retry while it exists. Only an exact v34 `404` permits authorization at +the ceiling; transient or ambiguous sentinel failures cannot select cached +policy. SDK releases must not replace the bootstrap: persisted state is bound +to the exact embedded-root epoch, and any mismatch fails closed before network +access. Even an immediate embedded-root successor would be an unauthenticated +out-of-band reanchor that could omit authority history. A future bootstrap +replacement requires an explicit, authenticated bridge-history migration in +both SDKs; it is not an ordinary SDK asset update. + +Offline root key material must be disjoint from every online role for the +repository's lifetime: a later root cannot move a former offline key online or +promote previously-online material into the root role. The timestamp, snapshot, +and targets roles may intentionally share the same online key material, as the +initial threshold-1 deployment does. Repository tooling must enforce both this +custody boundary and lifetime online-key non-reuse across the immutable root +history; clients persist and enforce both histories across every root epoch they +authenticate. + +For a new remote session, the browser completes the potentially slow TUF refresh +before creating the nonce or requesting the backend's ephemeral attestation +document. This preserves the backend pending secret's five-minute lifetime. It +then verifies the Nitro document, performs the expiry/currentness and complete +PCR0/PCR1/PCR2 authorization above, and immediately proceeds to key exchange. +An already verified cached session does not trigger this refresh. + +Manifest, bundle, and Sigstore trusted-root bodies are retained in the immutable +generation cache. A cached generation is usable only after all three exact bodies +are hash-checked against TUF and the bundle is cryptographically reverified. A +refresh may fall back only to such a fully reverified, unexpired last-known-good +generation after an explicit retryable +HTTP status (`404`, `408`, `429`, or `5xx`), timeout, or response interruption +after headers. Ambiguous pre-response Fetch rejection fails closed because +browsers do not distinguish an offline failure from a redirect blocked by +`redirect: "error"`. Cryptographic, rollback, redirect, schema, size, or +integrity failures never select cached policy. Timestamp validity is capped at +48 hours. + +The publisher must produce the shared browser/native TUF v1 profile: Ed25519 +top-level roles, threshold signatures, `consistent_snapshot: true`, sequential +root rotation, and SHA-256 length/hash descriptors. Browser limits are 64 KiB +root, 32 KiB timestamp, 128 KiB snapshot, 256 KiB targets, 128 KiB channel, +and manifest targets, 512 KiB Sigstore trusted root, and 2 MiB +portable bundle. Publication must enforce the smallest client limit. The +checked-in generated root is intentionally an unbootstrapped fail-closed +placeholder until the production TUF repository is created and reviewed. + +`@freedomofpress/sigstore-browser` 0.1.14 filters Fulcio certificate authorities +against the wall clock while loading a root. Maple first selects the one Rekor +key whose log ID matches the one required bundle entry, so overlapping Rekor +rotations do not depend on array order; that key must still be current when the +library loads it. The library does not expose a safe override for the remaining +current-time Fulcio filter. A sufficiently old release can therefore become +locally unverifiable after Sigstore retires trust material even while Maple TUF +still lists it; the SDK fails closed instead of weakening authenticated validity +periods. Promotion should replace such an active release before its Sigstore +trust material ages out. Mock attestation is limited to exact loopback development endpoints (plus the documented Android emulator alias in the Rust SDK). Do not weaken attestation, -PCR0 validation, or encrypted transport to accommodate a caller. +trusted-release validation, or encrypted transport to accommodate a caller. The SDKs use operating-system or Web Crypto randomness for keys, nonces, and session material. Never substitute deterministic or convenience randomness in @@ -42,6 +173,11 @@ production paths. ## TypeScript/React SDK +> **Unreleased breaking change:** npm 3.5.2 is the legacy PCR-file client. The +> dynamic TUF/Sigstore client below must ship as npm 4.0.0 (or another new major) +> only after the production TUF root and initial policy are published. Do not +> publish this draft under the current package version. + Install the package: ```sh @@ -60,7 +196,7 @@ export function AppProviders({ children }: { children: ReactNode }) { {children} @@ -104,6 +240,9 @@ bun run pack Only `dist/` is included in the package. +First bootstrap and publish the backend TUF repository, replace the placeholder +SDK root, and then release this TypeScript SDK major before updating consumers. + Publish a freshly built npm artifact with: ```sh @@ -119,6 +258,12 @@ Add the crate to a Rust application: opensecret = "3" ``` +The published 3.x crate still uses the legacy PCR trust contract. The dynamic +TUF/Sigstore implementation in this monorepo is unreleased and requires a new +major SDK release before external consumers should rely on the behavior +described here. See `rust/README.md` for the explicit activation warning and +release ordering. + The primary entry point is `OpenSecretClient`. See `rust/README.md` for native client examples and transport details. @@ -148,7 +293,7 @@ just publish-cargo - Keep the TypeScript and Rust attestation policies aligned intentionally; neither SDK's passing tests prove parity with the other. - Treat API compatibility, authentication state, encrypted retry behavior, and - PCR policy changes as security-sensitive. + trusted-release policy changes as security-sensitive. - Update source comments and focused tests with behavior changes instead of regenerating a standalone documentation site. - Validate the built npm package and Rust crate boundary before publishing a diff --git a/sdk/bun.lock b/sdk/bun.lock index 6e622fe90..8e4ad9e11 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@opensecret/react", "dependencies": { + "@freedomofpress/sigstore-browser": "0.1.14", "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", @@ -145,6 +146,12 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@freedomofpress/crypto-browser": ["@freedomofpress/crypto-browser@0.1.7", "", { "dependencies": { "@noble/curves": "^1.6.0" } }, "sha512-zjWmZDKdAu8g0Zq1IjBQ+sKQ/NpfzStBDFjy/qHUSMVEL4wNlNGtA7lhtw8v8asXa0yqF2QTYQ3rq6xCTQeADw=="], + + "@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.14", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-1dqc7HojiBcr/sJSAXjBwNz4+WGeeAJP8ENQnDxm+idVV0/YIxpfwXRNSJdNw6EXJuHviq/5kSPEBCxamPcrpQ=="], + + "@freedomofpress/tuf-browser": ["@freedomofpress/tuf-browser@0.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7" } }, "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], @@ -667,6 +674,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@freedomofpress/sigstore-browser/@noble/curves": ["@noble/curves@2.3.0", "", { "dependencies": { "@noble/hashes": "2.3.0" } }, "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg=="], + "@microsoft/api-extractor/minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="], "@microsoft/api-extractor/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -699,6 +708,8 @@ "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@freedomofpress/sigstore-browser/@noble/curves/@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], + "@microsoft/api-extractor/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], diff --git a/sdk/docs/PLATFORM.md b/sdk/docs/PLATFORM.md index 5d94d5f7c..c4a1ba14e 100644 --- a/sdk/docs/PLATFORM.md +++ b/sdk/docs/PLATFORM.md @@ -13,7 +13,7 @@ function App() { return ( @@ -351,8 +351,8 @@ function PlatformManagement() { ### Attestation Verification -- `pcrConfig`: The PCR0 trust policy enforced before non-loopback session establishment. Its environment defaults to production. Only the selected environment's embedded roots, custom roots, and pinned-key signed GitHub history are trusted; set `environment: "development"` explicitly for development enclaves. -- `getAttestation`: Gets an attested session after enforcing the effective PCR0 trust policy. +- `pcrConfig`: Selects the `"prod"` or `"dev"` trusted-release environment enforced before non-loopback session establishment. Before each new session, the SDK refreshes authenticated release policy from the fixed `https://attestations.trymaple.ai/tuf` origin, locally verifies the exact TUF-selected manifest with its portable Sigstore bundle and trusted root, and requires the complete PCR0/PCR1/PCR2 tuple to match one active manifest. Callers cannot add custom roots, builder allowlists, or runtime history URLs. Builder admission is enforced by release promotion; certificate identity and `build.builderId` remain audit provenance rather than a second client authorization pin. +- `getAttestation`: Gets an attested session after enforcing the effective trusted-release policy before key exchange. - `authenticate`: Authenticates an attestation document. - `parseAttestationForView`: Parses an attestation document for viewing. - `awsRootCertDer`: AWS root certificate in DER format. diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 5f655e7ff..bafe3e700 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -9,6 +9,7 @@ "version": "3.5.2", "license": "MIT", "dependencies": { + "@freedomofpress/sigstore-browser": "0.1.14", "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", @@ -882,6 +883,65 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@freedomofpress/crypto-browser": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@freedomofpress/crypto-browser/-/crypto-browser-0.1.7.tgz", + "integrity": "sha512-zjWmZDKdAu8g0Zq1IjBQ+sKQ/NpfzStBDFjy/qHUSMVEL4wNlNGtA7lhtw8v8asXa0yqF2QTYQ3rq6xCTQeADw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.6.0" + }, + "peerDependencies": { + "@noble/curves": "^1.6.0" + } + }, + "node_modules/@freedomofpress/sigstore-browser": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@freedomofpress/sigstore-browser/-/sigstore-browser-0.1.14.tgz", + "integrity": "sha512-1dqc7HojiBcr/sJSAXjBwNz4+WGeeAJP8ENQnDxm+idVV0/YIxpfwXRNSJdNw6EXJuHviq/5kSPEBCxamPcrpQ==", + "license": "MIT", + "dependencies": { + "@freedomofpress/crypto-browser": "^0.1.7", + "@freedomofpress/tuf-browser": "^0.1.11", + "@noble/curves": "^2.0.1" + } + }, + "node_modules/@freedomofpress/sigstore-browser/node_modules/@noble/curves": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.3.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@freedomofpress/sigstore-browser/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@freedomofpress/tuf-browser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@freedomofpress/tuf-browser/-/tuf-browser-0.1.11.tgz", + "integrity": "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ==", + "license": "MIT", + "dependencies": { + "@freedomofpress/crypto-browser": "^0.1.7" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "dev": true, @@ -1120,7 +1180,6 @@ }, "node_modules/@noble/curves": { "version": "1.9.7", - "dev": true, "license": "MIT", "dependencies": { "@noble/hashes": "1.8.0" @@ -1134,7 +1193,6 @@ }, "node_modules/@noble/hashes": { "version": "1.8.0", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" diff --git a/sdk/package.json b/sdk/package.json index 0d859e4e8..f11c65088 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -28,6 +28,7 @@ "react": "^18.0.0 || ^19.0.0" }, "dependencies": { + "@freedomofpress/sigstore-browser": "0.1.14", "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", diff --git a/sdk/rust/.env.example b/sdk/rust/.env.example index 1f6189e34..f2afe557b 100644 --- a/sdk/rust/.env.example +++ b/sdk/rust/.env.example @@ -1,7 +1,5 @@ # OpenSecret API Configuration VITE_OPEN_SECRET_API_URL=http://localhost:3000 -# PCR trust environment: production (default) or development -VITE_OPEN_SECRET_PCR_ENVIRONMENT=production # Test credentials VITE_TEST_EMAIL=your-email@example.com diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index ee58ebee1..e79fea861 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -155,6 +155,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -173,6 +197,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -188,6 +218,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -207,6 +247,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -309,6 +351,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -349,6 +424,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -437,6 +524,37 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.7.10" @@ -444,6 +562,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] @@ -462,6 +582,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -486,6 +617,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -503,6 +655,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecdsa" version = "0.16.9" @@ -574,6 +732,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -596,6 +760,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -611,6 +781,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.34" @@ -723,6 +909,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -732,7 +930,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -747,6 +945,19 @@ dependencies = [ "polyval", ] +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "group" version = "0.13.0" @@ -1091,6 +1302,69 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -1114,6 +1388,21 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1267,21 +1556,27 @@ dependencies = [ "chacha20poly1305", "chrono", "ciborium", + "directories", "dotenvy", "eventsource-stream", + "fs2", "futures", "hex", "hkdf", "http", + "jiff", "p256", "percent-encoding", "pin-project", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "ring", "serde", "serde_json", "sha2", + "sigstore-tuf", + "sigstore-verify", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-test", @@ -1294,6 +1589,12 @@ dependencies = [ "yasna", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "p256" version = "0.13.2" @@ -1329,6 +1630,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1380,6 +1691,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "poly1305" version = "0.8.0" @@ -1403,6 +1720,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1418,6 +1750,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1475,7 +1816,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -1511,12 +1852,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -1528,6 +1885,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1537,6 +1904,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -1558,7 +1934,18 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", ] [[package]] @@ -1634,6 +2021,38 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1654,7 +2073,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1682,6 +2101,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -1712,9 +2144,10 @@ version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1729,6 +2162,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1798,6 +2237,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1810,6 +2260,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1856,6 +2317,183 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "sigstore-bundle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5948e95f63e900fa92936ecf72c7f2251f83d27560a35a4aae560cc745f8b687" +dependencies = [ + "base64", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-crypto" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f27364b37bec104a904f10a675c8cdf05d5e48a980569368f0cd0c119b2652" +dependencies = [ + "aws-lc-rs", + "base64", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.9.5", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.20", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee85fd9fb550efd7c8a59447cb0ef4eb02f1258f3ffa80c88d38427c3930af3" +dependencies = [ + "base64", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-rekor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97c5a866f849a9445ae657bef0caa2db053a82827e6dae3a2b3de9c15a6a1a" +dependencies = [ + "base64", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389b54d1b8ace20ba86fdf90e5e655495f65f1abbc7d5d0eb7494defa9ad32f0" +dependencies = [ + "base64", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58fe92d4d8cf4b7215b927b70bc1245b6e0d70d801febdfe0cd265ad97ebf8b" +dependencies = [ + "aws-lc-rs", + "base64", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "jiff", + "rand 0.9.5", + "reqwest 0.13.4", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.20", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedac50883a917b7b434db22e2e6e853ace8c00f4a9c27f53e1e9c87e6d89fe4" +dependencies = [ + "globset", + "hex", + "jiff", + "serde", + "serde_json", + "sha2", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sigstore-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236474c535a3157839926a0ae18f9c0c22b151e49634da6e5b18ad7cac8a3b69" +dependencies = [ + "base64", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "sigstore-verify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f71aad0e1c5925d29ae2024f55f0f8898a7ad450c93668f99086624c421e0" +dependencies = [ + "base64", + "cms", + "const-oid", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.20", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "slab" version = "0.4.12" @@ -1948,7 +2586,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "system-configuration-sys", ] @@ -1963,6 +2601,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2067,6 +2718,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -2162,7 +2834,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -2271,6 +2943,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2334,6 +3012,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2431,6 +3118,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2606,6 +3315,12 @@ dependencies = [ "url", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" @@ -2624,6 +3339,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -2641,6 +3370,17 @@ dependencies = [ "time", ] +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index a1ed097af..8ebb66a87 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -6,7 +6,7 @@ authors = ["OpenSecret"] description = "Rust SDK for OpenSecret - secure AI API interactions with nitro attestation" license = "MIT" homepage = "https://opensecret.cloud" -repository = "https://github.com/OpenSecretCloud/OpenSecret-SDK" +repository = "https://github.com/OpenSecretCloud/Maple" documentation = "https://docs.rs/opensecret" keywords = ["opensecret", "ai", "encryption", "nitro", "attestation"] categories = ["cryptography", "api-bindings", "web-programming"] @@ -34,6 +34,8 @@ sha2 = "0.10" base64 = "0.22" ring = "0.17" # For certificate validation hex = "0.4" # For debug output +sigstore-tuf = { version = "=0.11.0", default-features = false } +sigstore-verify = { version = "=0.11.0", default-features = false } # X.509 and certificate handling x509-parser = "0.16" @@ -45,6 +47,10 @@ thiserror = "2.0" anyhow = "1.0" tracing = "0.1" chrono = { version = "0.4", features = ["serde"] } +jiff = "0.2" +directories = "6.0" +tempfile = "3.23" +fs2 = "0.4" # SSE support for streaming eventsource-stream = "0.2" diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 63fdd3b75..c014bd6db 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -17,6 +17,12 @@ Rust SDK for OpenSecret - secure AI API interactions with nitro attestation. ## Installation +The dynamic TUF/Sigstore implementation documented below is currently +unreleased. The latest crates.io version, 3.6.2, still has the legacy PCR trust +contract and must not be presented as containing these changes. Maple and its +proxy consume this in-tree source through path dependencies while the breaking +SDK release and dependent proxy release are reviewed and published in order. + Add to your `Cargo.toml`: ```toml @@ -27,16 +33,19 @@ futures = "0.3" http = "1" ``` +The version above is the latest legacy release. Update it to the new major only +after the TUF-enabled SDK has actually been published. + ## Quick Start ```rust -use opensecret::{OpenSecretClient, Pcr0Environment, Pcr0TrustPolicy, Result}; +use opensecret::{OpenSecretClient, Result}; use uuid::Uuid; #[tokio::main] async fn main() -> Result<()> { // Initialize client - let client = OpenSecretClient::new("https://api.opensecret.com")?; + let client = OpenSecretClient::new("https://api.opensecret.cloud")?; let client_id = Uuid::parse_str("your-client-id")?; // Establish secure session @@ -55,42 +64,122 @@ async fn main() -> Result<()> { } ``` -Production clients verify both the AWS Nitro attestation and the enclave's -PCR0 deployment identity. `OpenSecretClient::new` uses pinned official PCR0 -values and OpenSecret's signed production history. Development trust must be -selected explicitly and checks only the development roots and signed history: - -```rust -let development_client = OpenSecretClient::new_with_pcr0_environment( - "https://enclave.secretgpt.ai", - Pcr0Environment::Development, -)?; -``` - -The API-key equivalent is -`OpenSecretClient::new_with_api_key_and_pcr0_environment`. A signed PCR0 added -to the selected GitHub history is accepted without a client update. Neither -official policy falls back to the other environment. - -Custom deployments can add a static allowlist without replacing the selected -official trust policy: - -```rust -let policy = Pcr0TrustPolicy::official().with_additional_pcr0s([ - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", -])?; -let client = OpenSecretClient::new_with_pcr0_trust_policy( - "https://api.opensecret.cloud", - policy, -)?; -``` - -Use `Pcr0TrustPolicy::from_static_allowlist(...)` to disable remote history and -trust only an explicit custom set. Remote entries are size/time bounded and -must verify against the SDK's hardcoded OpenSecret P-384 signing key. Exact -localhost, loopback, and unspecified-address development endpoints continue to -use mock attestation; Android also supports the exact emulator alias -`10.0.2.2`. Other endpoints must use HTTPS. +Production clients verify both AWS Nitro authenticity and an atomic +PCR0/PCR1/PCR2 tuple before key exchange. Before every real attestation +handshake, the SDK refreshes the selected `prod` or `dev` channel from +`https://attestations.trymaple.ai/tuf/`. It verifies TUF root rotation, +signatures, versions, expiry, lengths, and hashes, then locally verifies each +active release's portable Sigstore bundle over the exact manifest bytes. The +Sigstore check includes the Fulcio certificate chain and SCT, Rekor inclusion +proof and signed checkpoint, integrated signing time, artifact signature, and +the exact manifest bytes. TUF authorizes the exact manifest, bundle, and +Sigstore trusted-root targets; a signer's certificate identity and issuer are +provenance evidence, not client-side release-authorization inputs. +This refresh happens before the SDK requests the backend's ephemeral Nitro +attestation key, whose five-minute lifetime must not be consumed by a cold TUF +refresh. Immediately before key exchange the SDK performs a non-network check +against the latest process-wide and durable rollback floors, rechecks signed +metadata expiry, and authorizes the complete PCR tuple. + +The convenience constructors recognize only the SDK's exact official origins. +A custom HTTPS origin must use `new_with_attestation_config` (or the API-key +equivalent) with an explicit `TrustedReleaseConfig` containing its repository +URL and bootstrap root. Unknown remote origins never inherit production trust. +Conversely, an official API origin accepts only the matching official channel, +the canonical `https://attestations.trymaple.ai/tuf/` repository, the SDK's +embedded bootstrap root, and a persistent rollback-state path. Explicit custom +configuration cannot replace or disable any part of that official trust domain; +mobile callers should use `official_with_cache_path` to select the durable path. + +The cache contains the minimum complete last-known-good TUF generation plus a +monotonic authenticated-observation journal; it is not a list of trusted PCRs. +Default desktop state is stored in the platform's durable application-data +directory, not its purgeable cache or temporary directory. Android and iOS +hosts that cannot expose a home directory must obtain a durable app-data file +path from the platform and create the official manager with +`TrustedReleaseManager::official_with_cache_path`; they can then pass that +manager to `OpenSecretClient::new_with_trusted_release_manager` (or the API-key +equivalent). Deleting this state explicitly resets the local rollback history. +If a refresh authenticates newer root, timestamp, snapshot, targets, or channel +metadata and then fails later, those version/hash/sequence floors are persisted +without activating a partial PCR policy. This prevents a restart from replaying +an older still-unexpired generation. A network/unavailability failure may use +cached authorization only after re-running all TUF and Sigstore verification at +the current time against the greatest observed floors. Each TUF repository HTTP +request has a 15-second total deadline, including its streamed body. The bounded +cold path permits at most 43 sequential repository requests (including the +root-34 absence sentinel), so an enclosing recovery budget must allow up to 645 +seconds of repository I/O; the Maple proxy supplies a 15-minute recovery +budget. The +signed timestamp may be valid for at most 48 hours. Invalid signatures, +rollback, expiry, redirects, +schema errors, or digest mismatches fail closed and do not fall back to cached +authorization. Root, timestamp, snapshot, and targets expiry are checked again +against a fresh clock reading after all downloads and Sigstore work, immediately +before the policy is returned, and the minimum signed expiry is checked once +more when the PCR tuple is authorized immediately before key exchange. The +cache also persists the greatest accepted channel sequence and exact channel +digest; a lower sequence or changed channel at the same sequence is rejected +even when newer TUF metadata signs it. +Production and development share one repository-level root and metadata history +so one channel cannot be selectively held on an older root, while their channel +sequence floors remain independent. Custom managers for the same repository +should therefore use the same cache path. + +Rollback floors retain bounded full-authority provenance and replay every +authenticated root transition in sequence. A metadata floor is cleared only +when the old key material cannot meet the replacement role's threshold; an +overlap rotation conservatively widens the floor's provenance because TUF +signatures can be detached. Consequently, additive or staged overlap is not a +compromise-recovery mechanism in this v1 profile. Recovery from a compromised +online role must use fresh, non-overlapping key custody (and any parent role +needed to replace a poisoned child descriptor). Duplicate aliases for one +cryptographic key, reassigning previously seen key material to another online +role, and later reauthorizing a retired online key are rejected. Release +operations must never move or reuse retired timestamp, snapshot, or targets +keys. Offline root-role key material must be completely disjoint from the +timestamp, snapshot, and targets roles for the repository's entire observed +lifetime: a key that has ever appeared offline may never move online, and a key +that has ever appeared online may never become a root key. The three online +roles may share one protected key in the initial deployment. Cache schema v4 +persists this bounded custody ledger; older unshipped draft cache schemas are +rejected rather than migrated without the missing history. + +The supported v1 client line keeps its embedded bootstrap at signed root version +1 and traverses every numbered remote root in order (the repository retains them +all, through root 33 / 32 rotations). The official constructors machine-check +that the embedded root is self-authenticating and signed version 1; explicit +`TrustedReleaseConfig` custom repositories may intentionally bootstrap at a +different version and are bounded at bootstrap version + 32. The SDK applies +that absolute span again while journaling and loading durable state, so a +dependency that adopts the 33rd transition before reporting its iteration +limit cannot make that extra root trusted on a retry. Replacing the official +embedded root or skipping an +intermediate root is forbidden until an explicit authenticated bridge-history +migration exists. A missing intermediate root therefore fails closed rather +than discarding persisted rollback history. + +An authenticated channel with no active releases is a valid emergency +revoke-all generation. It is committed to the cache and advances the channel +high-water mark, but its empty policy rejects every PCR tuple. An outage cannot +fall back past that revocation to an older active generation. + +The checked-in generated TUF root is intentionally a v1 unpublished placeholder +until the production repository is bootstrapped; it is the only official-root +sentinel exception. In that staging state, real +handshakes return `UnreleasedAttestationPolicy`; there is no GitHub PCR-history +fallback. Exact localhost, loopback, and unspecified-address development +endpoints use mock attestation only when the `mock-attestation` feature is +enabled; Android also supports the exact emulator alias `10.0.2.2`. Other +endpoints require HTTPS. + +Release promotion may admit only builders configured by the publisher and may +validate their certificate identity, issuer, and CI-specific claims before it +updates TUF. The Rust client does not download that promotion configuration or +bind authorization to a repository, workflow, CI provider, certificate +identity, or issuer. This keeps existing clients valid across builder and +repository migrations while retaining local signature, certificate-chain, +SCT, transparency-log, timestamp, artifact, TUF, and PCR verification. ## Inference APIs @@ -235,23 +324,27 @@ directory, matching the TypeScript SDK setup. Required environment variables in `.env.local`: ```bash VITE_OPEN_SECRET_API_URL=http://localhost:3000 -VITE_OPEN_SECRET_PCR_ENVIRONMENT=production VITE_TEST_CLIENT_ID=your-client-id-uuid ``` -Production is the default when `VITE_OPEN_SECRET_PCR_ENVIRONMENT` is omitted. -Set it to `development` when the configured URL is a hosted development enclave. +Official hosted origins select their fixed `prod` or `dev` channel. Custom +origins require an explicit `TrustedReleaseConfig`; an environment variable +cannot silently change an official origin's trust domain. Run tests: ```bash -# All tests (requires running server on localhost:3000) -cargo test --locked +# Hermetic library tests (no server required) +cargo test --locked --lib + +# Local integration tests (requires a server on localhost:3000 and explicitly +# enables the mock-attestation bypass for that loopback origin) +cargo test --locked --features mock-attestation # With output -cargo test --locked -- --nocapture +cargo test --locked --features mock-attestation -- --nocapture # Specific test -cargo test --locked test_login_signup_flow -- --nocapture +cargo test --locked --features mock-attestation test_login_signup_flow -- --nocapture ``` ## Examples diff --git a/sdk/rust/assets/attestations_tuf_root.generated.json b/sdk/rust/assets/attestations_tuf_root.generated.json new file mode 100644 index 000000000..8b2f75f30 --- /dev/null +++ b/sdk/rust/assets/attestations_tuf_root.generated.json @@ -0,0 +1,5 @@ +{ + "schema": "https://attestations.trymaple.ai/schemas/unpublished-tuf-root/v1", + "status": "unpublished", + "message": "Generated production TUF root metadata has not been published yet. Release builds must replace this file." +} diff --git a/sdk/rust/src/attestation.rs b/sdk/rust/src/attestation.rs index 281751cb1..8eca1519e 100644 --- a/sdk/rust/src/attestation.rs +++ b/sdk/rust/src/attestation.rs @@ -26,7 +26,7 @@ pub struct AttestationDocument { /// This verifies the certificate chain, document signature, and nonce. Nitro /// authenticity alone does not identify an OpenSecret deployment. Production /// callers should use `OpenSecretClient`, which additionally enforces its -/// configured `Pcr0TrustPolicy` before key exchange. +/// freshly TUF/Sigstore-verified `TrustedReleasePolicy` before key exchange. #[derive(Default)] pub struct AttestationVerifier { expected_pcrs: Option>>, diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index 7901dd8ca..ff81fd7bc 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -2,9 +2,9 @@ use crate::{ attestation::{AttestationDocument, AttestationVerifier}, cbor::{self, Value as CborValue}, crypto::{self}, - error::{Error, Result}, - pcr::{Pcr0Environment, Pcr0TrustPolicy}, + error::{Error, InferenceTimeoutPhase, Result}, session::SessionManager, + trusted_release::{AttestationEnvironment, TrustedReleaseConfig, TrustedReleaseManager}, types::*, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -17,8 +17,9 @@ use reqwest::{ Client, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::{net::IpAddr, pin::Pin}; +use std::{future::Future, net::IpAddr, pin::Pin, sync::Arc, time::Duration}; use tokio::sync::Mutex; +use tokio::time::Instant; use uuid::Uuid; /// A decrypted response body returned by [`OpenSecretClient::send_inference_request`]. @@ -42,13 +43,83 @@ struct EncryptedBody { const MAX_INFERENCE_SSE_LINE_BYTES: usize = 16 * 1024 * 1024; +struct InferencePhaseBudget { + phase: InferenceTimeoutPhase, + limit: Duration, + remaining: Duration, +} + +impl InferencePhaseBudget { + fn new(phase: InferenceTimeoutPhase, limit: Duration) -> Self { + Self { + phase, + limit, + remaining: limit, + } + } + + async fn run(&mut self, operation: F) -> Result + where + F: Future>, + { + let started = Instant::now(); + match tokio::time::timeout(self.remaining, operation).await { + Ok(result) => { + self.remaining = self.remaining.saturating_sub(started.elapsed()); + result + } + Err(_) => { + self.remaining = Duration::ZERO; + Err(Error::InferenceTimeout { + phase: self.phase, + timeout_secs: self + .limit + .as_secs() + .saturating_add(u64::from(self.limit.subsec_nanos() != 0)), + }) + } + } + } +} + +struct InferenceTimeoutBudgets { + ordinary_timeout: Duration, + recovery: InferencePhaseBudget, +} + +impl InferenceTimeoutBudgets { + fn new(ordinary_timeout: Duration, recovery_timeout: Duration) -> Self { + Self { + ordinary_timeout, + recovery: InferencePhaseBudget::new(InferenceTimeoutPhase::Recovery, recovery_timeout), + } + } + + fn ordinary_attempt(&self) -> InferencePhaseBudget { + InferencePhaseBudget::new(InferenceTimeoutPhase::Ordinary, self.ordinary_timeout) + } +} + +async fn run_with_optional_budget( + budget: Option<&mut InferencePhaseBudget>, + operation: F, +) -> Result +where + F: Future>, +{ + match budget { + Some(budget) => budget.run(operation).await, + None => operation.await, + } +} + pub struct OpenSecretClient { client: Client, base_url: String, session_manager: SessionManager, refresh_lock: Mutex<()>, use_mock_attestation: bool, - pcr0_trust_policy: Pcr0TrustPolicy, + trusted_release_manager: Arc, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -424,7 +495,7 @@ async fn collect_response_body(mut body: OpenSecretResponseBody) -> Result Result { +fn is_local_attestation_endpoint(base_url: &str) -> Result { let parsed = reqwest::Url::parse(base_url) .map_err(|error| Error::Configuration(format!("Invalid base URL: {error}")))?; if !matches!(parsed.scheme(), "http" | "https") { @@ -469,27 +540,88 @@ fn uses_mock_attestation(base_url: &str) -> Result { Ok(is_mock_host) } +fn uses_mock_attestation(base_url: &str) -> Result { + let is_local = is_local_attestation_endpoint(base_url)?; + let is_plain_http = reqwest::Url::parse(base_url) + .map_err(|error| Error::Configuration(format!("Invalid base URL: {error}")))? + .scheme() + == "http"; + + // Local mock documents deliberately bypass Nitro certificate, nonce, and + // trusted-release verification. Keep that capability out of normal SDK, + // proxy, and Maple builds even if they are pointed at a loopback URL. + Ok(cfg!(feature = "mock-attestation") && is_local && is_plain_http) +} + +fn official_attestation_environment(base_url: &str) -> Result> { + let parsed = reqwest::Url::parse(base_url) + .map_err(|error| Error::Configuration(format!("Invalid base URL: {error}")))?; + Ok(match parsed.origin().ascii_serialization().as_str() { + "https://api.opensecret.cloud" + | "https://developer.opensecret.cloud" + | "https://enclave.trymaple.ai" => Some(AttestationEnvironment::Production), + "https://enclave.secretgpt.ai" => Some(AttestationEnvironment::Development), + _ => None, + }) +} + +fn default_attestation_environment(base_url: &str) -> Result { + if let Some(environment) = official_attestation_environment(base_url)? { + return Ok(environment); + } + if is_local_attestation_endpoint(base_url)? { + // The mock path is feature-gated and bypasses this policy, but client + // construction still selects a manager even though mock handshakes do + // not refresh the embedded TUF root. + return Ok(AttestationEnvironment::Production); + } + Err(Error::Configuration( + "No default attestation environment is defined for this origin; use new_with_attestation_config with an explicit TrustedReleaseConfig" + .to_string(), + )) +} + +fn validate_official_origin_trust_domain( + base_url: &str, + manager: &TrustedReleaseManager, +) -> Result<()> { + if let Some(expected) = official_attestation_environment(base_url)? { + manager.validate_official_trust_domain(expected)?; + } + Ok(()) +} + impl OpenSecretClient { - /// Construct a client using the official production PCR0 trust roots. + /// Construct a client using the dynamic TUF channel selected by the exact + /// official origin. pub fn new(base_url: impl Into) -> Result { - Self::new_with_pcr0_environment(base_url, Pcr0Environment::default()) + let base_url = base_url.into(); + let environment = default_attestation_environment(&base_url)?; + Self::new_with_trusted_release_manager( + base_url, + TrustedReleaseManager::official(environment)?, + ) } - /// Construct a client using one explicit official PCR0 environment. - pub fn new_with_pcr0_environment( + /// Construct a client with an explicit TUF repository and bootstrap root. + pub fn new_with_attestation_config( base_url: impl Into, - pcr0_environment: Pcr0Environment, + config: TrustedReleaseConfig, ) -> Result { - Self::new_with_pcr0_trust_policy(base_url, Pcr0TrustPolicy::official_for(pcr0_environment)) + Self::new_with_trusted_release_manager( + base_url, + Arc::new(TrustedReleaseManager::new(config)?), + ) } - /// Construct a client with an explicit PCR0 trust policy. - pub fn new_with_pcr0_trust_policy( + /// Construct a client with a caller-shared dynamic policy manager. + pub fn new_with_trusted_release_manager( base_url: impl Into, - pcr0_trust_policy: Pcr0TrustPolicy, + trusted_release_manager: Arc, ) -> Result { let base_url = base_url.into(); let use_mock = uses_mock_attestation(&base_url)?; + validate_official_origin_trust_domain(&base_url, &trusted_release_manager)?; Ok(Self { client: Client::new(), @@ -497,36 +629,45 @@ impl OpenSecretClient { session_manager: SessionManager::new(), refresh_lock: Mutex::new(()), use_mock_attestation: use_mock, - pcr0_trust_policy, + trusted_release_manager, }) } - /// Construct an API-key client using the official production PCR0 trust roots. + /// Construct an API-key client using the dynamic TUF channel selected by + /// the exact official origin. pub fn new_with_api_key(base_url: impl Into, api_key: String) -> Result { - Self::new_with_api_key_and_pcr0_environment(base_url, api_key, Pcr0Environment::default()) + let base_url = base_url.into(); + let environment = default_attestation_environment(&base_url)?; + Self::new_with_api_key_and_trusted_release_manager( + base_url, + api_key, + TrustedReleaseManager::official(environment)?, + ) } - /// Construct an API-key client using one explicit official PCR0 environment. - pub fn new_with_api_key_and_pcr0_environment( + /// Construct an API-key client with an explicit TUF repository and + /// bootstrap root. + pub fn new_with_api_key_and_attestation_config( base_url: impl Into, api_key: String, - pcr0_environment: Pcr0Environment, + config: TrustedReleaseConfig, ) -> Result { - Self::new_with_api_key_and_pcr0_trust_policy( + Self::new_with_api_key_and_trusted_release_manager( base_url, api_key, - Pcr0TrustPolicy::official_for(pcr0_environment), + Arc::new(TrustedReleaseManager::new(config)?), ) } - /// Construct an API-key client with an explicit PCR0 trust policy. - pub fn new_with_api_key_and_pcr0_trust_policy( + /// Construct an API-key client with a caller-shared policy manager. + pub fn new_with_api_key_and_trusted_release_manager( base_url: impl Into, api_key: String, - pcr0_trust_policy: Pcr0TrustPolicy, + trusted_release_manager: Arc, ) -> Result { let base_url = base_url.into(); let use_mock = uses_mock_attestation(&base_url)?; + validate_official_origin_trust_domain(&base_url, &trusted_release_manager)?; Ok(Self { client: Client::new(), @@ -534,7 +675,7 @@ impl OpenSecretClient { session_manager: SessionManager::new_with_api_key(api_key), refresh_lock: Mutex::new(()), use_mock_attestation: use_mock, - pcr0_trust_policy, + trusted_release_manager, }) } @@ -547,21 +688,22 @@ impl OpenSecretClient { } pub async fn perform_attestation_handshake(&self) -> Result<()> { - // Generate a nonce - let nonce = Uuid::new_v4().to_string(); - - // Step 1: Get attestation document - let attestation_doc = self.get_attestation_document(&nonce).await?; - - // Step 2: Parse and verify attestation document if !self.use_mock_attestation { + // Refresh dynamic trust before requesting the backend's ephemeral + // attestation key. The backend keeps that key for five minutes; + // a cold TUF/Sigstore refresh can legitimately take longer. + let policy = self.trusted_release_manager.refresh_policy().await?; + let nonce = Uuid::new_v4().to_string(); + let attestation_doc = self.get_attestation_document(&nonce).await?; let verifier = AttestationVerifier::new(); let doc = verifier .verify_attestation_document(&attestation_doc.attestation_document, &nonce)?; - self.establish_session_from_verified_attestation(&nonce, doc) + self.establish_session_from_verified_attestation_with_policy(&nonce, doc, &policy) .await } else { // For mock mode, extract without full verification + let nonce = Uuid::new_v4().to_string(); + let attestation_doc = self.get_attestation_document(&nonce).await?; let doc = self.parse_mock_attestation(&attestation_doc.attestation_document)?; self.establish_session_from_document(&nonce, doc).await } @@ -569,17 +711,33 @@ impl OpenSecretClient { /// Establish a session from a Nitro-authenticated document. /// - /// Keeping PCR0 enforcement in the same path as key exchange makes the + /// Keeping full trusted-release enforcement in the same path as key exchange makes the /// fail-before-key-exchange ordering explicit and independently testable. + #[cfg(test)] async fn establish_session_from_verified_attestation( &self, nonce: &str, doc: AttestationDocument, ) -> Result<()> { - let pcr0 = doc.pcrs.get(&0).ok_or_else(|| { - Error::AttestationVerificationFailed("Missing PCR0 in attestation document".to_string()) - })?; - self.pcr0_trust_policy.verify_pcr0(pcr0).await?; + let policy = self.trusted_release_manager.refresh_policy().await?; + self.establish_session_from_verified_attestation_with_policy(nonce, doc, &policy) + .await + } + + async fn establish_session_from_verified_attestation_with_policy( + &self, + nonce: &str, + doc: AttestationDocument, + policy: &crate::trusted_release::TrustedReleasePolicy, + ) -> Result<()> { + // A concurrent tab/process may have persisted a revoke or newer + // channel after the pre-nonce refresh. Recheck local durable/shared + // floors without another network refresh, then recheck metadata expiry + // and the atomic PCR tuple immediately before key exchange. + self.trusted_release_manager + .assert_policy_current(policy) + .await?; + policy.verify_attestation(&doc)?; self.establish_session_from_document(nonce, doc).await } @@ -939,6 +1097,39 @@ impl OpenSecretClient { pub async fn send_inference_request( &self, request: InferenceRequest, + ) -> Result { + self.send_inference_request_inner(request, None).await + } + + /// Sends an inference request with separate ordinary and recovery budgets. + /// + /// Each actual inference attempt—including response headers and a buffered + /// non-SSE response body—gets `ordinary_timeout`. Explicit recovery work + /// shares one cumulative `recovery_timeout` across the whole call. That + /// recovery budget covers direct reattestation as well as access-token + /// recovery, which may itself need to reattest. SSE bodies remain streams + /// after their headers arrive and must be governed by the caller's stream + /// idle timeout. + pub async fn send_inference_request_with_timeouts( + &self, + request: InferenceRequest, + ordinary_timeout: Duration, + recovery_timeout: Duration, + ) -> Result { + self.send_inference_request_inner( + request, + Some(InferenceTimeoutBudgets::new( + ordinary_timeout, + recovery_timeout, + )), + ) + .await + } + + async fn send_inference_request_inner( + &self, + request: InferenceRequest, + mut timeout_budgets: Option, ) -> Result { let (parts, body) = request.into_parts(); if parts.uri.scheme().is_some() || parts.uri.authority().is_some() { @@ -966,51 +1157,91 @@ impl OpenSecretClient { loop { let auth = self.resolve_auth(AuthHeaderMode::ApiKeyOrJwt)?; - let result = self - .send_inference_request_once( + let mut ordinary_budget = timeout_budgets + .as_ref() + .map(InferenceTimeoutBudgets::ordinary_attempt); + let result = run_with_optional_budget( + ordinary_budget.as_mut(), + self.send_inference_request_once( &parts.method, &path_and_query, &headers, body.clone(), &auth, - ) - .await; + ), + ) + .await; match result { Ok((response, session_key)) if response.status().is_success() => { - return self.finish_inference_response(response, session_key).await + return run_with_optional_budget( + ordinary_budget.as_mut(), + self.finish_inference_response(response, session_key), + ) + .await; } Ok((response, session_key)) => { let recovery = classify_response_recovery(response.status(), response.headers()); if replayed { - return self.finish_inference_response(response, session_key).await; + return run_with_optional_budget( + ordinary_budget.as_mut(), + self.finish_inference_response(response, session_key), + ) + .await; } match recovery { Some(RecoveryAction::Reattest) => { - self.perform_attestation_handshake().await?; + let recovery_budget = timeout_budgets + .as_mut() + .map(|budgets| &mut budgets.recovery); + run_with_optional_budget( + recovery_budget, + self.perform_attestation_handshake(), + ) + .await?; replayed = true; } Some(RecoveryAction::RefreshAccessToken) => { - if matches!( + let recovery_budget = timeout_budgets + .as_mut() + .map(|budgets| &mut budgets.recovery); + match run_with_optional_budget( + recovery_budget, self.recover_auth_after_unauthorized( AuthHeaderMode::ApiKeyOrJwt, &auth, - ) - .await, - Ok(true) - ) { - replayed = true; - } else { - return self.finish_inference_response(response, session_key).await; + ), + ) + .await + { + Ok(true) => replayed = true, + Err(error @ Error::InferenceTimeout { .. }) => return Err(error), + Ok(false) | Err(_) => { + return run_with_optional_budget( + ordinary_budget.as_mut(), + self.finish_inference_response(response, session_key), + ) + .await + } } } - None => return self.finish_inference_response(response, session_key).await, + None => { + return run_with_optional_budget( + ordinary_budget.as_mut(), + self.finish_inference_response(response, session_key), + ) + .await; + } } } Err(Error::Session(_)) if !recovered_missing_session => { - self.perform_attestation_handshake().await?; + let recovery_budget = timeout_budgets + .as_mut() + .map(|budgets| &mut budgets.recovery); + run_with_optional_budget(recovery_budget, self.perform_attestation_handshake()) + .await?; recovered_missing_session = true; } Err(error) => return Err(error), @@ -2623,14 +2854,10 @@ mod tests { use crate::PushNotificationKeyPair; use futures::StreamExt; use serde_json::json; - use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex as StdMutex, - }, - time::Duration, - }; + use std::collections::HashMap; + #[cfg(feature = "mock-attestation")] + use std::sync::{atomic::AtomicUsize, atomic::Ordering, Arc, Mutex as StdMutex}; + use std::time::Duration; use wiremock::{ matchers::{header, method, path, query_param}, Match, Mock, MockServer, Request, Respond, ResponseTemplate, @@ -2816,10 +3043,12 @@ mod tests { } } + #[cfg(feature = "mock-attestation")] struct AttestationResponder { server_public_key: [u8; 32], } + #[cfg(feature = "mock-attestation")] impl Respond for AttestationResponder { fn respond(&self, request: &Request) -> ResponseTemplate { let nonce = request.url.path().rsplit('/').next().unwrap_or_default(); @@ -2831,12 +3060,31 @@ mod tests { } } + #[cfg(feature = "mock-attestation")] + struct DelayedAttestationResponder { + server_public_key: [u8; 32], + delay: Duration, + } + + #[cfg(feature = "mock-attestation")] + impl Respond for DelayedAttestationResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + AttestationResponder { + server_public_key: self.server_public_key, + } + .respond(request) + .set_delay(self.delay) + } + } + + #[cfg(feature = "mock-attestation")] struct KeyExchangeResponder { server_secret_key: [u8; 32], session_key: [u8; 32], session_id: String, } + #[cfg(feature = "mock-attestation")] impl Respond for KeyExchangeResponder { fn respond(&self, request: &Request) -> ResponseTemplate { let body: KeyExchangeRequest = serde_json::from_slice(request.body.as_ref()).unwrap(); @@ -2857,12 +3105,14 @@ mod tests { } } + #[cfg(feature = "mock-attestation")] #[derive(Clone)] struct PerNonceAttestationResponder { server_secrets: Arc>>, next_key: Arc, } + #[cfg(feature = "mock-attestation")] impl Respond for PerNonceAttestationResponder { fn respond(&self, request: &Request) -> ResponseTemplate { let nonce = request.url.path().rsplit('/').next().unwrap_or_default(); @@ -2884,12 +3134,14 @@ mod tests { } } + #[cfg(feature = "mock-attestation")] #[derive(Clone)] struct PerNonceKeyExchangeResponder { server_secrets: Arc>>, session_key: [u8; 32], } + #[cfg(feature = "mock-attestation")] impl Respond for PerNonceKeyExchangeResponder { fn respond(&self, request: &Request) -> ResponseTemplate { let body: KeyExchangeRequest = serde_json::from_slice(request.body.as_ref()).unwrap(); @@ -2921,6 +3173,7 @@ mod tests { } } + #[cfg(feature = "mock-attestation")] fn build_mock_attestation_document(nonce: &str, server_public_key: &[u8; 32]) -> String { let payload = CborValue::Map(vec![ ( @@ -3105,11 +3358,73 @@ mod tests { async fn test_client_creation() { let client = OpenSecretClient::new("http://localhost:3000").unwrap(); assert_eq!(client.base_url, "http://localhost:3000"); + } + + #[test] + fn official_origins_reject_custom_trust_domains_but_custom_origins_allow_them() { + let directory = tempfile::tempdir().unwrap(); + let custom_config = TrustedReleaseConfig::new_with_cache_path( + AttestationEnvironment::Production, + "https://attestations.example/tuf/", + b"custom bootstrap".to_vec(), + directory.path().join("custom.json"), + ) + .unwrap(); + let custom_manager = Arc::new(TrustedReleaseManager::new(custom_config).unwrap()); + + let error = OpenSecretClient::new_with_trusted_release_manager( + "https://api.opensecret.cloud", + Arc::clone(&custom_manager), + ) + .err() + .expect("an official origin must reject a custom repository and bootstrap"); + assert!( + matches!(error, Error::Configuration(message) if message.contains("canonical attestation repository")) + ); + + let error = OpenSecretClient::new_with_api_key_and_trusted_release_manager( + "https://api.opensecret.cloud", + "test-key".to_string(), + Arc::clone(&custom_manager), + ) + .err() + .expect("the API-key constructor must enforce the same official trust domain"); + assert!(matches!(error, Error::Configuration(_))); + + OpenSecretClient::new_with_trusted_release_manager( + "https://custom.example", + custom_manager, + ) + .expect("a custom HTTPS origin may use its explicitly configured trust domain"); + + let official = TrustedReleaseManager::official_with_cache_path( + AttestationEnvironment::Production, + directory.path().join("official.json"), + ) + .unwrap(); + OpenSecretClient::new_with_trusted_release_manager( + "https://api.opensecret.cloud", + official, + ) + .expect("an official origin accepts the exact official durable trust domain"); + } + + #[cfg(not(feature = "mock-attestation"))] + #[test] + fn local_mock_bypass_is_disabled_without_feature() { + let client = OpenSecretClient::new("http://localhost:3000").unwrap(); + assert!(!client.use_mock_attestation); + } + + #[cfg(feature = "mock-attestation")] + #[test] + fn local_mock_bypass_requires_explicit_feature() { + let client = OpenSecretClient::new("http://localhost:3000").unwrap(); assert!(client.use_mock_attestation); } #[tokio::test] - async fn cross_environment_pcr0_fails_before_key_exchange() { + async fn unreleased_policy_fails_before_key_exchange() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/key_exchange")) @@ -3118,11 +3433,16 @@ mod tests { .mount(&mock_server) .await; - let production_policy = - Pcr0TrustPolicy::official_for(Pcr0Environment::Production).without_remote_history(); - let client = - OpenSecretClient::new_with_pcr0_trust_policy(mock_server.uri(), production_policy) - .unwrap(); + let production_policy = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + Vec::new(), + ); + let client = OpenSecretClient::new_with_trusted_release_manager( + mock_server.uri(), + TrustedReleaseManager::fixed_for_test(production_policy), + ) + .unwrap(); let document = synthetic_verified_attestation(DEVELOPMENT_PCR0); let nonce = Uuid::new_v4().to_string(); @@ -3131,36 +3451,75 @@ mod tests { .await .unwrap_err(); - assert!(matches!(error, Error::AttestationVerificationFailed(_))); + assert!(matches!(error, Error::UnreleasedAttestationPolicy { .. })); assert!(client.get_session_id().unwrap().is_none()); mock_server.verify().await; } #[tokio::test] - async fn development_environment_accepts_development_pcr0_before_key_exchange() { + async fn dynamic_policy_refresh_fails_before_requesting_an_attestation() { let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock_server) + .await; Mock::given(method("POST")) .and(path("/key_exchange")) .respond_with(ResponseTemplate::new(500)) - .expect(1) + .expect(0) .mount(&mock_server) .await; - let client = OpenSecretClient::new_with_pcr0_environment( + let manager = TrustedReleaseManager::official(AttestationEnvironment::Production).unwrap(); + let mut client = + OpenSecretClient::new_with_trusted_release_manager(mock_server.uri(), manager).unwrap(); + // Exercise the production path even in all-feature test builds, where + // loopback HTTP would otherwise select the explicit mock bypass. + client.use_mock_attestation = false; + assert!(matches!( + client.perform_attestation_handshake().await, + Err(Error::UnreleasedAttestationPolicy { .. }) + )); + mock_server.verify().await; + } + + #[tokio::test] + async fn concurrent_revoke_blocks_a_held_policy_before_key_exchange() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/key_exchange")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock_server) + .await; + let held = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 7, + Vec::new(), + ); + let manager = TrustedReleaseManager::fixed_for_test(held.clone()); + let client = OpenSecretClient::new_with_trusted_release_manager( mock_server.uri(), - Pcr0Environment::Development, + Arc::clone(&manager), ) .unwrap(); - let document = synthetic_verified_attestation(DEVELOPMENT_PCR0); - let nonce = Uuid::new_v4().to_string(); + let revoked = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 8, + Vec::new(), + ); + manager.install_policy_floor_for_test(&revoked); let error = client - .establish_session_from_verified_attestation(&nonce, document) + .establish_session_from_verified_attestation_with_policy( + &Uuid::new_v4().to_string(), + synthetic_verified_attestation(DEVELOPMENT_PCR0), + &held, + ) .await .unwrap_err(); - - assert!(matches!(error, Error::Api { status: 500, .. })); - assert!(client.get_session_id().unwrap().is_none()); + assert!(matches!(error, Error::TrustedReleasePolicy(_))); mock_server.verify().await; } @@ -3171,24 +3530,44 @@ mod tests { "https://example.com/localhost", "https://example.com/127.0.0.1", ] { - let client = OpenSecretClient::new(url).unwrap(); + let policy = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + Vec::new(), + ); + let client = OpenSecretClient::new_with_trusted_release_manager( + url, + TrustedReleaseManager::fixed_for_test(policy), + ) + .unwrap(); assert!(!client.use_mock_attestation, "unexpected mock URL: {url}"); } - assert!( - OpenSecretClient::new("http://localhost:3000") - .unwrap() - .use_mock_attestation - ); - assert!( - OpenSecretClient::new("http://127.0.0.1:3000") - .unwrap() - .use_mock_attestation + for url in [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://[::1]:3000", + ] { + let client = OpenSecretClient::new(url).unwrap(); + assert_eq!( + client.use_mock_attestation, + cfg!(feature = "mock-attestation"), + "unexpected mock-attestation feature behavior for {url}" + ); + } + + let policy = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + Vec::new(), ); assert!( - OpenSecretClient::new("http://[::1]:3000") - .unwrap() - .use_mock_attestation + !OpenSecretClient::new_with_trusted_release_manager( + "https://localhost:3000", + TrustedReleaseManager::fixed_for_test(policy), + ) + .unwrap() + .use_mock_attestation ); } @@ -3212,13 +3591,24 @@ mod tests { fn android_emulator_alias_is_not_a_desktop_mock_bypass() { let client = OpenSecretClient::new("http://10.0.2.2:3000"); if cfg!(target_os = "android") { - assert!(client.unwrap().use_mock_attestation); + assert_eq!( + client.unwrap().use_mock_attestation, + cfg!(feature = "mock-attestation") + ); } else { assert!(client.is_err()); + let policy = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + Vec::new(), + ); assert!( - !OpenSecretClient::new("https://10.0.2.2:3000") - .unwrap() - .use_mock_attestation + !OpenSecretClient::new_with_trusted_release_manager( + "https://10.0.2.2:3000", + TrustedReleaseManager::fixed_for_test(policy), + ) + .unwrap() + .use_mock_attestation ); } } @@ -3235,6 +3625,7 @@ mod tests { assert_eq!(tokens.refresh_token.as_deref(), Some("refresh")); } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn concurrent_attestation_handshakes_keep_each_nonce_public_key() { let mock_server = MockServer::start().await; @@ -3777,6 +4168,7 @@ mod tests { mock_server.verify().await; } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn target_replay_budget_is_shared_across_recovery_reasons() { let mock_server = MockServer::start().await; @@ -3857,6 +4249,7 @@ mod tests { mock_server.verify().await; } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn expired_target_with_stale_refresh_session_repairs_each_layer_once() { let mock_server = MockServer::start().await; @@ -3970,6 +4363,7 @@ mod tests { mock_server.verify().await; } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn establishing_a_missing_local_session_does_not_consume_target_replay() { let mock_server = MockServer::start().await; @@ -4283,6 +4677,147 @@ mod tests { assert!(stream.next().await.is_none()); } + #[tokio::test(start_paused = true)] + async fn inference_phase_budget_is_cumulative_within_one_attempt() { + let budgets = InferenceTimeoutBudgets::new(Duration::from_secs(3), Duration::from_secs(10)); + let mut first_attempt = budgets.ordinary_attempt(); + + first_attempt + .run(async { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(()) + }) + .await + .unwrap(); + let error = first_attempt + .run(async { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(()) + }) + .await + .unwrap_err(); + assert!(matches!( + error, + Error::InferenceTimeout { + phase: InferenceTimeoutPhase::Ordinary, + timeout_secs: 3, + } + )); + + // A replay is a new actual attempt and therefore receives a fresh + // ordinary budget rather than inheriting the exhausted body budget. + let mut replay_attempt = budgets.ordinary_attempt(); + replay_attempt + .run(async { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(()) + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn inference_transport_reports_ordinary_response_timeout() { + let mock_server = MockServer::start().await; + let client = + OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); + let session_id = Uuid::new_v4(); + let session_key = crypto::generate_random_bytes::<32>(); + client + .session_manager + .set_session(session_id, session_key) + .unwrap(); + + Mock::given(method("POST")) + .and(path("/v1/embeddings")) + .and(header("authorization", "Bearer api_key")) + .and(header("x-session-id", session_id.to_string())) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(200)) + .set_body_json(encrypted_response_bytes(&session_key, b"{}")), + ) + .expect(1) + .mount(&mock_server) + .await; + + let request = HttpRequest::builder() + .method(http::Method::POST) + .uri("/v1/embeddings") + .body(Bytes::from_static(br#"{"model":"x","input":"y"}"#)) + .unwrap(); + let result = client + .send_inference_request_with_timeouts( + request, + Duration::from_millis(50), + Duration::from_secs(1), + ) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("delayed inference response unexpectedly beat ordinary timeout"), + }; + + assert!(matches!( + error, + Error::InferenceTimeout { + phase: InferenceTimeoutPhase::Ordinary, + timeout_secs: 1, + } + )); + mock_server.verify().await; + } + + #[tokio::test] + async fn inference_sse_body_is_caller_timed_after_headers() { + let mock_server = MockServer::start().await; + let client = + OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); + let session_id = Uuid::new_v4(); + let session_key = crypto::generate_random_bytes::<32>(); + client + .session_manager + .set_session(session_id, session_key) + .unwrap(); + let plaintext = br#"{"delta":"still available"}"#; + let sse_body = format!( + "data: {}\n\ndata: [DONE]\n\n", + encrypted_sse_bytes(&session_key, plaintext) + ); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream")) + .expect(1) + .mount(&mock_server) + .await; + + let request = HttpRequest::builder() + .method(http::Method::POST) + .uri("/v1/chat/completions") + .body(Bytes::from_static(br#"{"stream":true}"#)) + .unwrap(); + let response = client + .send_inference_request_with_timeouts( + request, + Duration::from_millis(100), + Duration::from_secs(1), + ) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(150)).await; + let body = collect_response_body(response.into_body()).await.unwrap(); + assert_eq!( + body, + Bytes::from(format!( + "data: {}\n\ndata: [DONE]\n\n", + String::from_utf8_lossy(plaintext) + )) + ); + mock_server.verify().await; + } + #[tokio::test] async fn inference_transport_preserves_raw_request_and_response_bytes() { let mock_server = MockServer::start().await; @@ -4709,6 +5244,7 @@ mod tests { ); } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn inference_transport_establishes_attestation_and_replays_exact_bytes() { let mock_server = MockServer::start().await; @@ -4798,6 +5334,183 @@ mod tests { ); } + #[cfg(feature = "mock-attestation")] + #[tokio::test] + async fn inference_recovery_may_outlive_ordinary_attempt_budget() { + let mock_server = MockServer::start().await; + let client = + OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); + let stale_session_id = Uuid::new_v4(); + let stale_session_key = crypto::generate_random_bytes::<32>(); + let server_secret_key = crypto::generate_random_bytes::<32>(); + let server_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); + let fresh_session_id = Uuid::new_v4(); + let fresh_session_key = crypto::generate_random_bytes::<32>(); + let response_body = Bytes::from_static(br#"{"recovered":true}"#); + client + .session_manager + .set_session(stale_session_id, stale_session_key) + .unwrap(); + + Mock::given(method("POST")) + .and(path("/v1/embeddings")) + .and(header("x-session-id", stale_session_id.to_string())) + .respond_with(v1_error_response( + 400, + Some("session_not_found"), + "stale session", + )) + .expect(1) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(PathPrefixMatcher("/attestation/")) + .respond_with(DelayedAttestationResponder { + server_public_key: server_public_key.to_bytes(), + delay: Duration::from_millis(250), + }) + .expect(1) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/key_exchange")) + .respond_with(KeyExchangeResponder { + server_secret_key, + session_key: fresh_session_key, + session_id: fresh_session_id.to_string(), + }) + .expect(1) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/v1/embeddings")) + .and(header("x-session-id", fresh_session_id.to_string())) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(50)) + .set_body_json(encrypted_response_bytes(&fresh_session_key, &response_body)), + ) + .expect(1) + .mount(&mock_server) + .await; + + let request = HttpRequest::builder() + .method(http::Method::POST) + .uri("/v1/embeddings") + .body(Bytes::from_static(br#"{"model":"x","input":"y"}"#)) + .unwrap(); + let response = client + .send_inference_request_with_timeouts( + request, + Duration::from_millis(100), + Duration::from_secs(1), + ) + .await + .unwrap(); + + assert_eq!( + collect_response_body(response.into_body()).await.unwrap(), + response_body + ); + mock_server.verify().await; + } + + #[cfg(feature = "mock-attestation")] + #[tokio::test] + async fn inference_direct_and_jwt_recovery_share_one_cumulative_budget() { + let mock_server = MockServer::start().await; + let client = OpenSecretClient::new(mock_server.uri()).unwrap(); + let server_secret_key = crypto::generate_random_bytes::<32>(); + let server_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); + let session_id = Uuid::new_v4(); + let session_key = crypto::generate_random_bytes::<32>(); + client + .session_manager + .set_tokens( + "expired_access".to_string(), + Some("refresh_token".to_string()), + ) + .unwrap(); + + Mock::given(method("GET")) + .and(PathPrefixMatcher("/attestation/")) + .respond_with(DelayedAttestationResponder { + server_public_key: server_public_key.to_bytes(), + delay: Duration::from_millis(200), + }) + .expect(1) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/key_exchange")) + .respond_with(KeyExchangeResponder { + server_secret_key, + session_key, + session_id: session_id.to_string(), + }) + .expect(1) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/v1/embeddings")) + .and(header("authorization", "Bearer expired_access")) + .and(header("x-session-id", session_id.to_string())) + .respond_with(v1_error_response( + 401, + Some("access_token_expired"), + "expired access token", + )) + .expect(1) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/refresh")) + .and(MissingHeaderMatcher("authorization")) + .and(header("x-session-id", session_id.to_string())) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(400)) + .set_body_json(encrypted_response( + &session_key, + &json!({ + "access_token": "fresh_access", + "refresh_token": "fresh_refresh", + }), + )), + ) + .expect(1) + .mount(&mock_server) + .await; + + let request = HttpRequest::builder() + .method(http::Method::POST) + .uri("/v1/embeddings") + .body(Bytes::from_static(br#"{"model":"x","input":"y"}"#)) + .unwrap(); + let result = client + .send_inference_request_with_timeouts( + request, + Duration::from_millis(150), + Duration::from_millis(450), + ) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("cumulative recovery unexpectedly exceeded its budget"), + }; + + assert!(matches!( + error, + Error::InferenceTimeout { + phase: InferenceTimeoutPhase::Recovery, + timeout_secs: 1, + } + )); + mock_server.verify().await; + } + #[tokio::test] async fn inference_sse_transport_preserves_framing_across_arbitrary_chunks() { let session_key = [30u8; 32]; @@ -5041,6 +5754,7 @@ mod tests { ); } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn test_refresh_reestablishes_attestation_without_sending_auth_headers() { let mock_server = MockServer::start().await; @@ -5991,6 +6705,7 @@ mod tests { mock_server.verify().await; } + #[cfg(feature = "mock-attestation")] #[tokio::test] async fn agent_stream_stale_session_reattests_and_decrypts() { let mock_server = MockServer::start().await; diff --git a/sdk/rust/src/error.rs b/sdk/rust/src/error.rs index 97716213b..d34fc70ac 100644 --- a/sdk/rust/src/error.rs +++ b/sdk/rust/src/error.rs @@ -1,5 +1,23 @@ use thiserror::Error; +/// Phase whose explicit inference-request time budget was exhausted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InferenceTimeoutPhase { + /// Request transmission, response headers, or buffered response body. + Ordinary, + /// Session or authentication recovery, including dynamic trust refresh and reattestation. + Recovery, +} + +impl std::fmt::Display for InferenceTimeoutPhase { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Ordinary => "ordinary", + Self::Recovery => "recovery", + }) + } +} + #[derive(Error, Debug)] pub enum Error { #[error("HTTP request failed: {0}")] @@ -17,6 +35,23 @@ pub enum Error { #[error("Attestation verification failed: {0}")] AttestationVerificationFailed(String), + #[error( + "No published trusted enclave release is available for attestation environment '{environment}'" + )] + UnreleasedAttestationPolicy { environment: String }, + + #[error("Trusted enclave release policy is invalid: {0}")] + TrustedReleasePolicy(String), + + #[error("Trusted enclave release policy network is unavailable: {0}")] + TrustedReleaseNetwork(String), + + #[error("Inference {phase} phase timed out after {timeout_secs} seconds")] + InferenceTimeout { + phase: InferenceTimeoutPhase, + timeout_secs: u64, + }, + #[error("Session error: {0}")] Session(String), diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 4dd2ee9ed..509b8cbb5 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -3,13 +3,15 @@ mod cbor; pub mod client; pub mod crypto; pub mod error; -pub mod pcr; pub mod push; pub mod session; +pub mod trusted_release; pub mod types; pub use client::{InferenceRequest, InferenceResponse, OpenSecretClient, OpenSecretResponseBody}; -pub use error::{Error, Result}; -pub use pcr::{Pcr0Environment, Pcr0TrustPolicy}; +pub use error::{Error, InferenceTimeoutPhase, Result}; pub use push::*; +pub use trusted_release::{ + AttestationEnvironment, TrustedReleaseConfig, TrustedReleaseManager, TrustedReleasePolicy, +}; pub use types::*; diff --git a/sdk/rust/src/pcr.rs b/sdk/rust/src/pcr.rs deleted file mode 100644 index 0f55f7c51..000000000 --- a/sdk/rust/src/pcr.rs +++ /dev/null @@ -1,585 +0,0 @@ -use crate::{error::Error, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use reqwest::{redirect::Policy, Client, Url}; -use ring::signature; -use serde::Deserialize; -use std::collections::HashSet; -use std::time::Duration; - -const PCR0_HEX_LEN: usize = 96; -const PCR0_BYTES_LEN: usize = 48; -const MAX_REMOTE_HISTORY_BYTES: usize = 1024 * 1024; -const MAX_REMOTE_HISTORY_ENTRIES: usize = 2048; -const MAX_REMOTE_HISTORY_URLS: usize = 4; -const MAX_REMOTE_HISTORY_URL_BYTES: usize = 2048; -const REMOTE_HISTORY_TIMEOUT: Duration = Duration::from_secs(5); - -/// OpenSecret's P-384 PCR-history verification key in SPKI DER form. -/// -/// This key is the trust root for remote history entries. Replacing a history -/// URL cannot expand trust without a signature made by the corresponding -/// private key. -const PCR_HISTORY_VERIFICATION_KEY_B64: &str = - "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEHiUY9kFWK1GqBGzczohhwEwElXzgWLDZa9R6wBx3JOBocgSt9+UIzZlJbPDjYeGBfDUXh7Z62BG2vVsh2NgclLB5S7A2ucBBtb1wd8vSQHP8jpdPhZX1slauPgbnROIP"; - -pub const OFFICIAL_PRODUCTION_PCR_HISTORY_URL: &str = - "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrProdHistory.json"; -pub const OFFICIAL_DEVELOPMENT_PCR_HISTORY_URL: &str = - "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrDevHistory.json"; - -const OFFICIAL_PRODUCTION_PCR0S: &[&str] = &[ - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b", - "74ed417f88cb0ca76c4a3d10f278bd010f1d3f95eafb254d4732511bb50e404507a4049b779c5230137e4091a5582271", - "9043fcab93b972d3c14ad2dc8fa78ca7ad374fc937c02435681772a003f7a72876bc4d578089b5c4cf3fe9b480f1aabb", - "52c3595b151d93d8b159c257301bfd5aa6f49210de0c55a6cd6df5ebeee44e4206cab950500f5d188f7fa14e6d900b75", - "91cb67311e910cce68cd5b7d0de77aa40610d87c6681439b44c46c3ff786ae643956ab2c812478a1da8745b259f07a45", - "859065ac81b81d3735130ba08b8af72a7256b603fefb74faabae25ed28cca6edcaa7c10ea32b5948d675c18a9b0f2b1d", - "acd82a7d3943e23e95a9dc3ce0b0107ea358d6287f9e3afa245622f7c7e3e0a66142a928b6efcc02f594a95366d3a99d", -]; - -const OFFICIAL_DEVELOPMENT_PCR0S: &[&str] = &[ - "62c0407056217a4c10764ed9045694c29fa93255d3cc04c2f989cdd9a1f8050c8b169714c71f1118ebce2fcc9951d1a9", - "cb95519905443f9f66f05f63c548b61ad1561a27fd5717b69285861aaea3c3063fe12a2571773b67fea3c6c11b4d8ec6", - "deb5895831b5e4286f5a2dcf5e9c27383821446f8df2b465f141d10743599be20ba3bb381ce063bf7139cc89f7f61d4c", - "70ba26c6af1ec3b57ce80e1adcc0ee96d70224d4c7a078f427895cdf68e1c30f09b5ac4c456588d872f3f21ff77c036b", - "669404ea71435b8f498b48db7816a5c2ab1d258b1a77685b11d84d15a73189504d79c4dee13a658de9f4a0cbfc39cfe8", - "a791bf92c25ffdfd372660e460a0e238c6778c090672df6509ae4bc065cf8668b6baac6b6a11d554af53ee0ff0172ad5", - "c4285443b87b9b12a6cea3bef1064ec060f652b235a297095975af8f134e5ed65f92d70d4616fdec80af9dff48bb9f35", -]; - -/// OpenSecret deployment environment whose PCR0 trust roots should be used. -/// -/// Production is the fail-closed default. Development must be selected -/// explicitly by clients that connect to an OpenSecret development enclave. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Pcr0Environment { - #[default] - Production, - Development, -} - -/// PCR0 deployment-identity policy enforced after Nitro document validation. -/// -/// The default policy trusts OpenSecret's pinned production PCR0 values and -/// falls back to the signed official production history. Use -/// [`Self::official_for`] to explicitly select development trust roots, or -/// [`Self::from_static_allowlist`] for a custom deployment that must not use -/// remote history. -#[derive(Debug, Clone)] -pub struct Pcr0TrustPolicy { - trusted_pcr0s: HashSet, - remote_history_urls: Vec, -} - -impl Pcr0TrustPolicy { - /// Return the official production policy used by default. - pub fn official() -> Self { - Self::official_for(Pcr0Environment::Production) - } - - /// Return the official policy for one explicit OpenSecret environment. - /// - /// The selected policy contains only that environment's pinned PCR0 roots - /// and signed history URL. It never falls back to the other environment. - pub fn official_for(environment: Pcr0Environment) -> Self { - let (pcr0s, remote_history_url) = match environment { - Pcr0Environment::Production => ( - OFFICIAL_PRODUCTION_PCR0S, - OFFICIAL_PRODUCTION_PCR_HISTORY_URL, - ), - Pcr0Environment::Development => ( - OFFICIAL_DEVELOPMENT_PCR0S, - OFFICIAL_DEVELOPMENT_PCR_HISTORY_URL, - ), - }; - let trusted_pcr0s = pcr0s.iter().map(|pcr0| (*pcr0).to_string()).collect(); - let remote_history_urls = - vec![Url::parse(remote_history_url).expect("official PCR history URL must be valid")]; - - Self { - trusted_pcr0s, - remote_history_urls, - } - } - - /// Build a remote-disabled policy containing only caller-supplied PCR0s. - pub fn from_static_allowlist(pcr0s: I) -> Result - where - I: IntoIterator, - S: AsRef, - { - let mut policy = Self { - trusted_pcr0s: HashSet::new(), - remote_history_urls: Vec::new(), - }; - policy.add_pcr0s(pcr0s)?; - if policy.trusted_pcr0s.is_empty() { - return Err(Error::Configuration( - "PCR0 static allowlist must not be empty".to_string(), - )); - } - Ok(policy) - } - - /// Add caller-supplied PCR0 values to this policy. - pub fn with_additional_pcr0s(mut self, pcr0s: I) -> Result - where - I: IntoIterator, - S: AsRef, - { - self.add_pcr0s(pcr0s)?; - Ok(self) - } - - /// Disable signed remote history and retain only this policy's static set. - pub fn without_remote_history(mut self) -> Self { - self.remote_history_urls.clear(); - self - } - - /// Replace the default remote history locations. - /// - /// Every accepted entry must still verify against OpenSecret's hardcoded - /// signing key. HTTPS is required except for an exact loopback host, which - /// is allowed to support deterministic local testing and signed mirrors. - pub fn with_remote_history_urls(mut self, urls: I) -> Result - where - I: IntoIterator, - S: AsRef, - { - let parsed = urls - .into_iter() - .map(|url| parse_remote_history_url(url.as_ref())) - .collect::>>()?; - if parsed.is_empty() || parsed.len() > MAX_REMOTE_HISTORY_URLS { - return Err(Error::Configuration(format!( - "PCR history requires between 1 and {MAX_REMOTE_HISTORY_URLS} URLs" - ))); - } - self.remote_history_urls = parsed; - Ok(self) - } - - fn add_pcr0s(&mut self, pcr0s: I) -> Result<()> - where - I: IntoIterator, - S: AsRef, - { - for pcr0 in pcr0s { - let pcr0 = pcr0.as_ref(); - validate_pcr0_hex(pcr0)?; - self.trusted_pcr0s.insert(pcr0.to_string()); - } - Ok(()) - } - - pub(crate) async fn verify_pcr0(&self, pcr0: &[u8]) -> Result<()> { - if pcr0.len() != PCR0_BYTES_LEN { - return Err(Error::AttestationVerificationFailed(format!( - "PCR0 must be {PCR0_BYTES_LEN} bytes" - ))); - } - if pcr0.iter().all(|byte| *byte == 0) { - return Err(Error::AttestationVerificationFailed( - "PCR0 must not be all zero".to_string(), - )); - } - let pcr0_hex = hex::encode(pcr0); - if self.trusted_pcr0s.contains(&pcr0_hex) { - return Ok(()); - } - - // URL validation applies to the complete network request. Do not let a - // valid HTTPS history location redirect to HTTP, loopback, or another - // destination that has not passed `parse_remote_history_url`. - let history_client = Client::builder().redirect(Policy::none()).build()?; - for url in &self.remote_history_urls { - let history = match fetch_remote_history(&history_client, url).await { - Ok(history) => history, - Err(_) => continue, - }; - if history - .iter() - .any(|entry| entry.pcr0 == pcr0_hex && entry.has_valid_signature()) - { - return Ok(()); - } - } - - Err(Error::AttestationVerificationFailed( - "PCR0 is not approved by the configured trust policy".to_string(), - )) - } -} - -impl Default for Pcr0TrustPolicy { - fn default() -> Self { - Self::official() - } -} - -#[derive(Debug, Deserialize)] -struct PcrHistoryEntry { - #[serde(rename = "PCR0")] - pcr0: String, - #[serde(rename = "PCR1")] - pcr1: String, - #[serde(rename = "PCR2")] - pcr2: String, - timestamp: u64, - signature: String, -} - -impl PcrHistoryEntry { - fn validate(&self) -> Result<()> { - validate_pcr0_hex(&self.pcr0)?; - validate_pcr0_hex(&self.pcr1)?; - validate_pcr0_hex(&self.pcr2)?; - if self.timestamp == 0 { - return Err(Error::AttestationVerificationFailed( - "PCR history timestamp must be nonzero".to_string(), - )); - } - let signature = BASE64.decode(&self.signature)?; - if signature.len() != 96 { - return Err(Error::AttestationVerificationFailed( - "PCR history signature must be 96 bytes".to_string(), - )); - } - Ok(()) - } - - fn has_valid_signature(&self) -> bool { - let Ok(signature_bytes) = BASE64.decode(&self.signature) else { - return false; - }; - let Ok(spki) = BASE64.decode(PCR_HISTORY_VERIFICATION_KEY_B64) else { - return false; - }; - let Some(public_key) = spki.get(spki.len().saturating_sub(97)..) else { - return false; - }; - if public_key.first() != Some(&0x04) { - return false; - } - - signature::UnparsedPublicKey::new(&signature::ECDSA_P384_SHA384_FIXED, public_key) - .verify(self.pcr0.as_bytes(), &signature_bytes) - .is_ok() - } -} - -async fn fetch_remote_history(client: &Client, url: &Url) -> Result> { - tokio::time::timeout( - REMOTE_HISTORY_TIMEOUT, - fetch_remote_history_inner(client, url), - ) - .await - .map_err(|_| { - Error::AttestationVerificationFailed("PCR history request timed out".to_string()) - })? -} - -async fn fetch_remote_history_inner(client: &Client, url: &Url) -> Result> { - let mut response = client.get(url.clone()).send().await?; - if !response.status().is_success() { - return Err(Error::AttestationVerificationFailed( - "PCR history request failed".to_string(), - )); - } - if response - .content_length() - .is_some_and(|length| length > MAX_REMOTE_HISTORY_BYTES as u64) - { - return Err(Error::AttestationVerificationFailed( - "PCR history response is too large".to_string(), - )); - } - - let mut body = Vec::new(); - while let Some(chunk) = response.chunk().await? { - if body.len().saturating_add(chunk.len()) > MAX_REMOTE_HISTORY_BYTES { - return Err(Error::AttestationVerificationFailed( - "PCR history response is too large".to_string(), - )); - } - body.extend_from_slice(&chunk); - } - - let entries: Vec = serde_json::from_slice(&body)?; - if entries.is_empty() || entries.len() > MAX_REMOTE_HISTORY_ENTRIES { - return Err(Error::AttestationVerificationFailed(format!( - "PCR history requires between 1 and {MAX_REMOTE_HISTORY_ENTRIES} entries" - ))); - } - for entry in &entries { - entry.validate()?; - } - Ok(entries) -} - -fn validate_pcr0_hex(pcr0: &str) -> Result<()> { - if pcr0.len() != PCR0_HEX_LEN - || !pcr0 - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(Error::Configuration( - "PCR0 values must be 96 lowercase hexadecimal characters".to_string(), - )); - } - if pcr0.bytes().all(|byte| byte == b'0') { - return Err(Error::Configuration( - "PCR0 values must not be all zero".to_string(), - )); - } - Ok(()) -} - -fn parse_remote_history_url(value: &str) -> Result { - if value.len() > MAX_REMOTE_HISTORY_URL_BYTES { - return Err(Error::Configuration( - "PCR history URL is too long".to_string(), - )); - } - let url = Url::parse(value) - .map_err(|error| Error::Configuration(format!("Invalid PCR history URL: {error}")))?; - if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { - return Err(Error::Configuration( - "PCR history URL must not contain credentials or a fragment".to_string(), - )); - } - let is_loopback = url.host_str().is_some_and(|host| { - let host = host.trim_end_matches('.'); - let address_host = host - .strip_prefix('[') - .and_then(|host| host.strip_suffix(']')) - .unwrap_or(host); - host.eq_ignore_ascii_case("localhost") - || address_host - .parse::() - .is_ok_and(|address| address.is_loopback()) - }); - if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback) { - return Err(Error::Configuration( - "PCR history URL must use HTTPS (HTTP is allowed only for loopback)".to_string(), - )); - } - Ok(url) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use wiremock::{matchers::path, Mock, MockServer, ResponseTemplate}; - - const SIGNED_PCR0: &str = - "3637534c33a8bafc5034d5763e441a481f161bbbe888e375ce14b016c7497dc4e550afe866bd8e65969b409d54766481"; - const SIGNED_PCR0_SIGNATURE: &str = - "GZTXC0Xt0+yAaAatmMUd37pUJpF0nRAOj3Df9qxDOvDvRkiTF8UbGlzlL4kIOi/nd7dXAaEqYnY7OlpyngHBED2CSTpRRwV0xGo109epfqUKWWudrFaXpMsJ+GRKJLFO"; - const UNKNOWN_PCR0: &str = concat!( - "1111111111111111", - "1111111111111111", - "1111111111111111", - "1111111111111111", - "1111111111111111", - "1111111111111111", - ); - const ALL_ZERO_PCR0: &str = concat!( - "0000000000000000", - "0000000000000000", - "0000000000000000", - "0000000000000000", - "0000000000000000", - "0000000000000000", - ); - - fn pcr_bytes(value: &str) -> Vec { - hex::decode(value).unwrap() - } - - fn history(signature: &str) -> serde_json::Value { - json!([{ - "PCR0": SIGNED_PCR0, - "PCR1": "e45de6f4e9809176f6adc68df999f87f32a602361247d5819d1edf11ac5a403cfbb609943705844251af85713a17c83a", - "PCR2": "fe0a6f7c29c7c4999571869f880b6d5086b377deaaf359e19ae824edacd6a9d90247b793a5f2d73c0e74e2f9630aeb4a", - "timestamp": 1743710235_u64, - "signature": signature, - "futureMetadata": { "release": "ignored" }, - }]) - } - - #[tokio::test] - async fn official_policy_defaults_to_production_only() { - let policy = Pcr0TrustPolicy::official(); - let default_policy = Pcr0TrustPolicy::default(); - let production_history = - Url::parse(OFFICIAL_PRODUCTION_PCR_HISTORY_URL).expect("valid production URL"); - - for candidate in [&policy, &default_policy] { - assert_eq!( - candidate.remote_history_urls, - vec![production_history.clone()] - ); - assert_eq!( - candidate.trusted_pcr0s.len(), - OFFICIAL_PRODUCTION_PCR0S.len() - ); - assert!(OFFICIAL_PRODUCTION_PCR0S - .iter() - .all(|pcr0| candidate.trusted_pcr0s.contains(*pcr0))); - assert!(OFFICIAL_DEVELOPMENT_PCR0S - .iter() - .all(|pcr0| !candidate.trusted_pcr0s.contains(*pcr0))); - } - - let static_policy = policy.without_remote_history(); - static_policy - .verify_pcr0(&pcr_bytes(OFFICIAL_PRODUCTION_PCR0S[0])) - .await - .unwrap(); - assert!(static_policy - .verify_pcr0(&pcr_bytes(OFFICIAL_DEVELOPMENT_PCR0S[0])) - .await - .is_err()); - } - - #[tokio::test] - async fn development_policy_excludes_production_trust() { - let policy = Pcr0TrustPolicy::official_for(Pcr0Environment::Development); - assert_eq!( - policy.remote_history_urls, - vec![Url::parse(OFFICIAL_DEVELOPMENT_PCR_HISTORY_URL).expect("valid development URL")] - ); - assert_eq!(policy.trusted_pcr0s.len(), OFFICIAL_DEVELOPMENT_PCR0S.len()); - assert!(OFFICIAL_DEVELOPMENT_PCR0S - .iter() - .all(|pcr0| policy.trusted_pcr0s.contains(*pcr0))); - assert!(OFFICIAL_PRODUCTION_PCR0S - .iter() - .all(|pcr0| !policy.trusted_pcr0s.contains(*pcr0))); - - let static_policy = policy.without_remote_history(); - static_policy - .verify_pcr0(&pcr_bytes(OFFICIAL_DEVELOPMENT_PCR0S[0])) - .await - .unwrap(); - assert!(static_policy - .verify_pcr0(&pcr_bytes(OFFICIAL_PRODUCTION_PCR0S[0])) - .await - .is_err()); - } - - #[tokio::test] - async fn all_zero_pcr0_is_never_trusted() { - assert!(Pcr0TrustPolicy::from_static_allowlist([ALL_ZERO_PCR0]).is_err()); - - let policy = Pcr0TrustPolicy::official().without_remote_history(); - let error = policy.verify_pcr0(&[0; PCR0_BYTES_LEN]).await.unwrap_err(); - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - } - - #[tokio::test] - async fn static_allowlist_approves_only_exact_pcr0() { - let policy = Pcr0TrustPolicy::from_static_allowlist([SIGNED_PCR0]).unwrap(); - policy.verify_pcr0(&pcr_bytes(SIGNED_PCR0)).await.unwrap(); - - let error = policy - .verify_pcr0(&[0x42; PCR0_BYTES_LEN]) - .await - .unwrap_err(); - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - } - - #[tokio::test] - async fn signed_remote_history_approves_matching_pcr0() { - let server = MockServer::start().await; - Mock::given(path("/history.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(history(SIGNED_PCR0_SIGNATURE))) - .expect(1) - .mount(&server) - .await; - let policy = Pcr0TrustPolicy::from_static_allowlist([UNKNOWN_PCR0]) - .unwrap() - .with_remote_history_urls([format!("{}/history.json", server.uri())]) - .unwrap(); - - policy.verify_pcr0(&pcr_bytes(SIGNED_PCR0)).await.unwrap(); - } - - #[tokio::test] - async fn invalid_remote_signature_fails_closed() { - let server = MockServer::start().await; - let invalid_signature = BASE64.encode([0u8; 96]); - Mock::given(path("/history.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(history(&invalid_signature))) - .expect(1) - .mount(&server) - .await; - let policy = Pcr0TrustPolicy::from_static_allowlist([UNKNOWN_PCR0]) - .unwrap() - .with_remote_history_urls([format!("{}/history.json", server.uri())]) - .unwrap(); - - let error = policy - .verify_pcr0(&pcr_bytes(SIGNED_PCR0)) - .await - .unwrap_err(); - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - } - - #[tokio::test] - async fn malformed_remote_history_fails_closed() { - let server = MockServer::start().await; - Mock::given(path("/history.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "PCR0": "too-short", - "PCR1": "too-short", - "PCR2": "too-short", - "timestamp": 1, - "signature": SIGNED_PCR0_SIGNATURE, - }]))) - .expect(1) - .mount(&server) - .await; - let policy = Pcr0TrustPolicy::from_static_allowlist([UNKNOWN_PCR0]) - .unwrap() - .with_remote_history_urls([format!("{}/history.json", server.uri())]) - .unwrap(); - - assert!(policy.verify_pcr0(&pcr_bytes(SIGNED_PCR0)).await.is_err()); - } - - #[tokio::test] - async fn remote_history_redirects_are_not_followed() { - let server = MockServer::start().await; - let redirected_url = format!("{}/redirected.json", server.uri()); - Mock::given(path("/history.json")) - .respond_with(ResponseTemplate::new(302).insert_header("location", redirected_url)) - .expect(1) - .mount(&server) - .await; - Mock::given(path("/redirected.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(history(SIGNED_PCR0_SIGNATURE))) - .expect(0) - .mount(&server) - .await; - let policy = Pcr0TrustPolicy::from_static_allowlist([UNKNOWN_PCR0]) - .unwrap() - .with_remote_history_urls([format!("{}/history.json", server.uri())]) - .unwrap(); - - assert!(policy.verify_pcr0(&pcr_bytes(SIGNED_PCR0)).await.is_err()); - } - - #[test] - fn rejects_unsafe_remote_history_urls() { - assert!(Pcr0TrustPolicy::official() - .with_remote_history_urls(["http://example.com/history.json"]) - .is_err()); - assert!(Pcr0TrustPolicy::official() - .with_remote_history_urls(["https://user@example.com/history.json"]) - .is_err()); - } -} diff --git a/sdk/rust/src/trusted_release.rs b/sdk/rust/src/trusted_release.rs new file mode 100644 index 000000000..aa555e2c5 --- /dev/null +++ b/sdk/rust/src/trusted_release.rs @@ -0,0 +1,9066 @@ +//! Dynamically refreshed trust policy for OpenSecret Nitro enclave releases. +//! +//! The SDK bootstraps from an embedded TUF root, then refreshes signed metadata +//! and targets from `attestations.trymaple.ai`. TUF selects the currently active +//! releases; each selected manifest is independently verified as a portable +//! Sigstore bundle. Only the complete PCR0/PCR1/PCR2 tuple from a fully verified +//! release can authorize an attestation. + +use crate::{ + attestation::AttestationDocument, + error::{Error, Result}, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use fs2::FileExt; +use futures::StreamExt; +use reqwest::{redirect::Policy as RedirectPolicy, Client, StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sigstore_tuf::{ + transport::FetchFuture, MetadataStore, Repository, StoreRepository, Updater, UpdaterConfig, +}; +use sigstore_verify::{ + trust_root::TrustedRoot as SigstoreTrustedRoot, + types::{Bundle, HashAlgorithm, SignatureContent}, + VerificationPolicy, Verifier, +}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashSet}, + fmt, + fs::{File, OpenOptions}, + future::Future, + io::Write, + path::{Path, PathBuf}, + sync::{Arc, Mutex as StdMutex, OnceLock}, + time::Duration, +}; +use tokio::sync::{watch, Mutex}; + +const REPOSITORY_URL: &str = "https://attestations.trymaple.ai/tuf/"; +const CHANNEL_SCHEMA: &str = "https://attestations.trymaple.ai/schemas/channel/v1"; +const MANIFEST_SCHEMA: &str = "https://attestations.trymaple.ai/schemas/nitro-eif-release/v1"; +const COMPONENT: &str = "opensecret-backend"; +const EIF_MEDIA_TYPE: &str = "application/vnd.aws.nitro.eif"; +const CACHE_SCHEMA: &str = "https://attestations.trymaple.ai/schemas/sdk-tuf-cache/v4"; +const UNPUBLISHED_ROOT_SCHEMA: &str = + "https://attestations.trymaple.ai/schemas/unpublished-tuf-root/v1"; +const SHA256_HEX_LEN: usize = 64; +const SHA384_HEX_LEN: usize = 96; +const SHA384_BYTES_LEN: usize = 48; +const MAX_ACTIVE_RELEASES: usize = 2; +const MAX_ROOT_BYTES: u64 = 64 * 1024; +const MAX_TIMESTAMP_BYTES: u64 = 32 * 1024; +const MAX_SNAPSHOT_BYTES: u64 = 128 * 1024; +const MAX_TARGETS_METADATA_BYTES: u64 = 256 * 1024; +const MAX_CHANNEL_BYTES: usize = 128 * 1024; +const MAX_SIGSTORE_ROOT_BYTES: usize = 512 * 1024; +const MAX_MANIFEST_BYTES: usize = 128 * 1024; +const MAX_BUNDLE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_CACHE_BYTES: u64 = 32 * 1024 * 1024; +const MAX_CACHE_ENTRIES: usize = 128; +const MAX_AUTHORITY_KEYS: usize = 128; +const MAX_ROOT_TRANSITIONS: u64 = 32; +const MAX_TIMESTAMP_VALIDITY_HOURS: i64 = 48; +const TUF_UNAVAILABLE_PREFIX: &str = "repository unavailable: "; +const TUF_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +const EMBEDDED_TUF_ROOT: &[u8] = include_bytes!("../assets/attestations_tuf_root.generated.json"); + +static PRODUCTION_MANAGER: OnceLock> = OnceLock::new(); +static DEVELOPMENT_MANAGER: OnceLock> = OnceLock::new(); +static REPOSITORY_MEMORY_STATES: OnceLock>>> = + OnceLock::new(); + +/// TUF channel selected for an enclave attestation. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "lowercase")] +pub enum AttestationEnvironment { + /// Public production enclave channel. + #[serde(rename = "prod")] + Production, + /// Explicit development enclave channel. + #[serde(rename = "dev")] + Development, +} + +impl AttestationEnvironment { + /// Wire value used in TUF target paths and signed documents. + pub const fn as_str(self) -> &'static str { + match self { + Self::Production => "prod", + Self::Development => "dev", + } + } +} + +impl fmt::Display for AttestationEnvironment { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Explicit configuration for a TUF-backed release-policy source. +#[derive(Clone, Debug)] +pub struct TrustedReleaseConfig { + environment: AttestationEnvironment, + repository_url: String, + tuf_root: Arc<[u8]>, + cache_path: Option, +} + +impl TrustedReleaseConfig { + /// Creates a policy source. The repository URL must be HTTPS and is + /// expected to contain `metadata/` and `targets/` below it. + pub fn new( + environment: AttestationEnvironment, + repository_url: impl Into, + tuf_root_json: impl Into>, + ) -> Result { + let repository_url = repository_url.into(); + let repository_url = validate_repository_base(&repository_url, false)?.to_string(); + let cache_path = Some(default_cache_path(&repository_url)?); + Ok(Self { + environment, + repository_url, + tuf_root: Arc::from(tuf_root_json.into()), + cache_path, + }) + } + + /// Creates a policy source with an application-owned durable state path. + /// + /// Mobile hosts should use this constructor because Rust cannot discover + /// an Android or iOS application sandbox without a platform context. The + /// path must live in durable application data, not an OS cache directory. + pub fn new_with_cache_path( + environment: AttestationEnvironment, + repository_url: impl Into, + tuf_root_json: impl Into>, + cache_path: impl Into, + ) -> Result { + let repository_url = repository_url.into(); + let repository_url = validate_repository_base(&repository_url, false)?.to_string(); + Ok(Self { + environment, + repository_url, + tuf_root: Arc::from(tuf_root_json.into()), + cache_path: Some(cache_path.into()), + }) + } + + /// Overrides the persistent cache file used for last-known-good metadata. + pub fn with_cache_path(mut self, cache_path: impl Into) -> Self { + self.cache_path = Some(cache_path.into()); + self + } + + /// Disables cross-process persistence for a custom API origin. The manager + /// still refreshes and verifies before every attestation handshake. + /// Official API origins reject managers without durable rollback state. + pub fn without_persistent_cache(mut self) -> Self { + self.cache_path = None; + self + } + + /// Selected release channel. + pub const fn environment(&self) -> AttestationEnvironment { + self.environment + } +} + +/// A fully verified, immutable set of active enclave PCR tuples. +#[derive(Clone, Debug)] +pub struct TrustedReleasePolicy { + environment: AttestationEnvironment, + sequence: u64, + policy_id: String, + repository_high_water: RepositoryHighWater, + valid_until: jiff::Timestamp, + releases: Vec, +} + +#[derive(Clone, Debug)] +struct TrustedRelease { + version: String, + pcr0: [u8; SHA384_BYTES_LEN], + pcr1: [u8; SHA384_BYTES_LEN], + pcr2: [u8; SHA384_BYTES_LEN], +} + +#[cfg(test)] +type TestReleaseTuple<'a> = (&'a str, [u8; 48], [u8; 48], [u8; 48]); + +impl TrustedReleasePolicy { + /// The environment this policy is permitted to authorize. + pub fn environment(&self) -> &str { + self.environment.as_str() + } + + /// Monotonic channel sequence authenticated by TUF. + pub const fn sequence(&self) -> u64 { + self.sequence + } + + /// SHA-256 of the exact authenticated channel bytes. + pub fn policy_id(&self) -> &str { + &self.policy_id + } + + /// Verifies PCR0, PCR1, and PCR2 as one indivisible release tuple. + pub fn verify_attestation(&self, document: &AttestationDocument) -> Result<()> { + self.verify_attestation_at(document, jiff::Timestamp::now()) + } + + fn verify_attestation_at( + &self, + document: &AttestationDocument, + now: jiff::Timestamp, + ) -> Result<()> { + if now >= self.valid_until { + return Err(policy_error( + "authenticated attestation policy metadata expired before PCR authorization", + )); + } + if self.releases.is_empty() { + return Err(Error::UnreleasedAttestationPolicy { + environment: self.environment.to_string(), + }); + } + if document.digest != "SHA384" { + return Err(Error::AttestationVerificationFailed(format!( + "Attestation digest must be SHA384, got '{}'", + document.digest + ))); + } + + let pcr0 = attestation_pcr(document, 0)?; + let pcr1 = attestation_pcr(document, 1)?; + let pcr2 = attestation_pcr(document, 2)?; + if self + .releases + .iter() + .any(|release| release.pcr0 == pcr0 && release.pcr1 == pcr1 && release.pcr2 == pcr2) + { + return Ok(()); + } + + let versions = self + .releases + .iter() + .map(|release| release.version.as_str()) + .collect::>() + .join(", "); + Err(Error::AttestationVerificationFailed(format!( + "PCR0/PCR1/PCR2 tuple is not active in authenticated channel {} for environment '{}' (active releases: {})", + self.policy_id, self.environment, versions + ))) + } + + #[cfg(test)] + pub(crate) fn for_test( + environment: AttestationEnvironment, + sequence: u64, + releases: Vec>, + ) -> Self { + Self { + environment, + sequence, + policy_id: "test-policy".to_string(), + repository_high_water: RepositoryHighWater::for_test(), + valid_until: jiff::Timestamp::MAX, + releases: releases + .into_iter() + .map(|(version, pcr0, pcr1, pcr2)| TrustedRelease { + version: version.to_string(), + pcr0, + pcr1, + pcr2, + }) + .collect(), + } + } +} + +/// Single-flight dynamic release-policy manager shared by SDK clients. +pub struct TrustedReleaseManager { + config: TrustedReleaseConfig, + repository: HttpTufRepository, + refresh_coordinator: Arc>, + memory_state: Arc, + #[cfg(test)] + fixed_policy: Option, +} + +#[derive(Clone, Default)] +struct MemoryHighWater { + repository: Option, + channels: BTreeMap, +} + +#[derive(Clone, Default)] +struct ProcessSecurityState { + high_water: MemoryHighWater, + root_history: BTreeMap>, +} + +#[derive(Default)] +struct RepositoryMemoryState { + state: StdMutex, +} + +#[derive(Default)] +struct RefreshCoordinator { + next_id: u64, + in_flight: Option<(u64, watch::Receiver>)>, +} + +type SharedRefreshResult = std::result::Result; + +#[derive(Clone, Debug)] +enum SharedRefreshError { + Network(String), + Policy(String), + Unreleased(String), + Other(String), +} + +impl SharedRefreshError { + fn from_error(error: &Error) -> Self { + match error { + Error::TrustedReleaseNetwork(message) => Self::Network(message.clone()), + Error::TrustedReleasePolicy(message) => Self::Policy(message.clone()), + Error::UnreleasedAttestationPolicy { environment } => { + Self::Unreleased(environment.clone()) + } + other => Self::Other(other.to_string()), + } + } + + fn into_error(self) -> Error { + match self { + Self::Network(message) => Error::TrustedReleaseNetwork(message), + Self::Policy(message) => Error::TrustedReleasePolicy(message), + Self::Unreleased(environment) => Error::UnreleasedAttestationPolicy { environment }, + Self::Other(message) => Error::Other(message), + } + } +} + +impl fmt::Debug for TrustedReleaseManager { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TrustedReleaseManager") + .field("environment", &self.config.environment) + .field("repository_url", &self.config.repository_url) + .field("cache_path", &self.config.cache_path) + .finish_non_exhaustive() + } +} + +impl TrustedReleaseManager { + /// Creates a manager for an explicit TUF repository and bootstrap root. + pub fn new(config: TrustedReleaseConfig) -> Result { + let repository = HttpTufRepository::new(&config.repository_url, false)?; + let memory_state = repository_memory_state(&config); + Ok(Self { + config, + repository, + refresh_coordinator: Arc::new(Mutex::new(RefreshCoordinator::default())), + memory_state, + #[cfg(test)] + fixed_policy: None, + }) + } + + /// Returns the process-wide manager for the official production or + /// development channel. + pub fn official(environment: AttestationEnvironment) -> Result> { + validate_official_embedded_root(EMBEDDED_TUF_ROOT)?; + let slot = match environment { + AttestationEnvironment::Production => &PRODUCTION_MANAGER, + AttestationEnvironment::Development => &DEVELOPMENT_MANAGER, + }; + if let Some(manager) = slot.get() { + return Ok(Arc::clone(manager)); + } + + let config = + TrustedReleaseConfig::new(environment, REPOSITORY_URL, EMBEDDED_TUF_ROOT.to_vec())?; + let candidate = Arc::new(Self::new(config)?); + Ok(Arc::clone(slot.get_or_init(|| candidate))) + } + + /// Creates an official-repository manager with a host-owned durable state + /// path while retaining the SDK's embedded TUF bootstrap root. + /// + /// This is intended for Android and iOS hosts, which must obtain their app + /// data directory from the platform and pass a file path below it. + pub fn official_with_cache_path( + environment: AttestationEnvironment, + cache_path: impl Into, + ) -> Result> { + validate_official_embedded_root(EMBEDDED_TUF_ROOT)?; + let config = TrustedReleaseConfig::new_with_cache_path( + environment, + REPOSITORY_URL, + EMBEDDED_TUF_ROOT.to_vec(), + cache_path, + )?; + Ok(Arc::new(Self::new(config)?)) + } + + /// Selected release channel. + pub const fn environment(&self) -> AttestationEnvironment { + self.config.environment + } + + pub(crate) fn validate_official_trust_domain( + &self, + expected_environment: AttestationEnvironment, + ) -> Result<()> { + if self.config.environment != expected_environment { + return Err(Error::Configuration(format!( + "Attestation environment '{}' is not allowed for this official origin; expected '{}'", + self.config.environment, + expected_environment.as_str() + ))); + } + if self.config.repository_url != REPOSITORY_URL { + return Err(Error::Configuration( + "Official API origins require the SDK's canonical attestation repository" + .to_string(), + )); + } + if self.config.tuf_root.as_ref() != EMBEDDED_TUF_ROOT { + return Err(Error::Configuration( + "Official API origins require the SDK's embedded TUF bootstrap root".to_string(), + )); + } + if self.config.cache_path.is_none() { + return Err(Error::Configuration( + "Official API origins require persistent attestation rollback state".to_string(), + )); + } + Ok(()) + } + + fn clone_for_refresh(&self) -> Self { + Self { + config: self.config.clone(), + repository: self.repository.clone(), + refresh_coordinator: Arc::clone(&self.refresh_coordinator), + memory_state: Arc::clone(&self.memory_state), + #[cfg(test)] + fixed_policy: self.fixed_policy.clone(), + } + } + + /// Refreshes TUF and verifies every active Sigstore bundle. Network failure + /// falls back only to a complete cached repository that still passes TUF's + /// signature, rollback, hash, and expiration checks at the current time. + pub async fn refresh_policy(&self) -> Result { + let worker = self.clone_for_refresh(); + coalesce_refresh(Arc::clone(&self.refresh_coordinator), move || async move { + worker.refresh_policy_inner().await + }) + .await + } + + /// Confirms, without network I/O, that a previously refreshed policy has + /// not been superseded by a newer repository or channel floor observed by + /// this process or persisted by another process. + pub(crate) async fn assert_policy_current(&self, policy: &TrustedReleasePolicy) -> Result<()> { + if policy.environment != self.config.environment { + return Err(policy_error( + "held attestation policy belongs to a different environment", + )); + } + let cache_guard = self.acquire_cache_lock().await?; + let cached = self.load_cache().await?; + let memory = self + .memory_state + .state + .lock() + .expect("repository memory-state mutex poisoned") + .clone(); + let latest = merge_loaded_security_high_water_states( + cached.repository_high_water.as_ref(), + cached.channel_high_water, + memory.high_water.repository.as_ref(), + &memory.high_water.channels, + &cached.entries, + &memory.root_history, + )?; + enforce_repository_high_water(&policy.repository_high_water, latest.repository.as_ref())?; + enforce_high_water(policy, latest.channels.get(&policy.environment))?; + drop(cache_guard); + Ok(()) + } + + async fn refresh_policy_inner(&self) -> Result { + self.refresh_policy_inner_with_verifier(&PortableBundleVerifier) + .await + } + + async fn refresh_policy_inner_with_verifier( + &self, + bundle_verifier: &dyn BundleVerifier, + ) -> Result { + #[cfg(test)] + if let Some(policy) = &self.fixed_policy { + return Ok(policy.clone()); + } + + if is_unpublished_root(&self.config.tuf_root) { + return Err(Error::UnreleasedAttestationPolicy { + environment: self.config.environment.to_string(), + }); + } + + // Hold an advisory process lock across read/refresh/write. Without it, + // a slower process could atomically overwrite a newer verified TUF + // generation with an older one after racing from the same cache. + let mut cache_guard = self.acquire_cache_lock().await?; + let cached = self.load_cache().await?; + let cached_repository_high_water = cached.repository_high_water; + let cached_channel_high_water = cached.channel_high_water; + let online_store = Arc::new(SnapshotStore::from_entries(cached.entries)); + let now = jiff::Timestamp::now(); + let online_result = resolve_policy( + self.repository.clone(), + Arc::clone(&online_store), + &self.config.tuf_root, + self.config.environment, + now, + bundle_verifier, + ) + .await; + let online_result = match online_result { + Ok(mut policy) => { + let memory_high_water = self + .memory_state + .state + .lock() + .expect("repository memory-state mutex poisoned") + .clone(); + let advanced = align_security_high_water_states_to_observed( + cached_repository_high_water.as_ref(), + cached_channel_high_water.clone(), + memory_high_water.high_water.repository.as_ref(), + &memory_high_water.high_water.channels, + &policy.repository_high_water, + &online_store.entries(), + ); + let advanced = match advanced { + Ok(advanced) => advanced, + Err(error) => return Err(error), + }; + let channel_high_water = advanced.channels; + let effective_prior_repository = advanced.repository; + let validation = (|| -> Result<()> { + enforce_repository_high_water( + &policy.repository_high_water, + effective_prior_repository.as_ref(), + )?; + let prior_channel = + channel_high_water + .get(&self.config.environment) + .filter(|floor| { + !safely_replaces_authority( + &floor.authority, + &policy.repository_high_water.targets_authority, + ) + }); + enforce_high_water(&policy, prior_channel) + })(); + match validation { + Ok(()) => { + let merged = merge_repository_observation( + effective_prior_repository.as_ref(), + &policy.repository_high_water, + ); + if let Some(error) = merged.error { + Err(RefreshFailure::Security(error)) + } else { + policy.repository_high_water = merged.high_water; + Ok((policy, channel_high_water)) + } + } + Err(error) => Err(RefreshFailure::Security(error)), + } + } + Err(error) => Err(error), + }; + + let result = match online_result { + Ok((policy, mut channel_high_water)) => { + let high_water = CacheHighWater::from_policy(&policy); + let entries = online_store.entries(); + let root_history = + root_history_for_process_state(&entries, &policy.repository_high_water)?; + retain_channel_floors_for_authority( + &mut channel_high_water, + &policy.repository_high_water.targets_authority, + )?; + if let Some(merged) = merge_high_water( + channel_high_water.get(&self.config.environment), + Some(&high_water), + )? { + channel_high_water.insert(self.config.environment, merged); + } + // Install process-wide monotonic floors before the fallible + // disk write. Production and development managers share this + // state because they consume one TUF repository and cache. + { + let mut memory = self + .memory_state + .state + .lock() + .expect("repository memory-state mutex poisoned"); + // The state loaded above already merged this shared memory + // under the cross-process cache lock. Install the complete + // authenticated generation atomically before disk I/O, so + // a failed write cannot make this process regress. + memory.high_water.channels = channel_high_water.clone(); + memory.high_water.repository = Some(policy.repository_high_water.clone()); + memory.root_history = root_history; + } + if let Some(cache_path) = self.config.cache_path.clone() { + let repository_id = repository_id(&self.config.repository_url); + let repository_high_water = policy.repository_high_water.clone(); + let persisted_channel_high_water = channel_high_water.clone(); + persist_cache_while_locked( + &mut cache_guard, + cache_path, + repository_id, + repository_high_water, + persisted_channel_high_water, + entries, + "cache", + ) + .await?; + } + Ok(policy) + } + Err(refresh_failure) => { + // Even a refresh that cannot activate a policy may have + // authenticated newer root or top-level metadata, or a newer + // channel sequence. Journal those observations before any + // fallback or error escapes so a restart cannot replay an + // older, still-unexpired generation. + let refresh_failure_message = refresh_failure.error_ref().to_string(); + let recorded = self + .record_authenticated_observation( + Arc::clone(&online_store), + now, + cached_repository_high_water.as_ref(), + cached_channel_high_water, + &mut cache_guard, + ) + .await + .map_err(|journal_error| { + policy_error(format!( + "attestation policy refresh failed ({refresh_failure_message}); authenticated observation journal update failed: {journal_error}" + )) + })?; + if let Some(observation_error) = recorded.observation_error { + return Err(observation_error); + } + let journal = recorded.cache; + + match refresh_failure { + RefreshFailure::Unavailable(online_error) => { + let offline_store = Arc::new(SnapshotStore::from_entries(journal.entries)); + match resolve_policy( + StoreRepository::new(Arc::clone(&offline_store)), + offline_store, + &self.config.tuf_root, + self.config.environment, + now, + bundle_verifier, + ) + .await + { + Ok(policy) => { + enforce_repository_high_water( + &policy.repository_high_water, + journal.repository_high_water.as_ref(), + )?; + let prior_channel = journal + .channel_high_water + .get(&self.config.environment) + .filter(|floor| { + !safely_replaces_authority( + &floor.authority, + &policy.repository_high_water.targets_authority, + ) + }); + enforce_high_water(&policy, prior_channel)?; + tracing::warn!( + %online_error, + environment = %self.config.environment, + "using still-valid authenticated attestation policy cache" + ); + Ok(policy) + } + Err(offline_error) => { + tracing::warn!( + %online_error, + cached_error = %offline_error.into_error(), + "online TUF refresh failed and cached policy was unusable" + ); + Err(online_error) + } + } + } + RefreshFailure::UnavailableAfterChannel(error) + | RefreshFailure::Security(error) => Err(error), + } + } + }; + drop(cache_guard); + result + } + + async fn acquire_cache_lock(&self) -> Result> { + let Some(path) = self.config.cache_path.clone() else { + return Ok(None); + }; + match tokio::task::spawn_blocking(move || lock_cache(&path)).await { + Ok(Ok(file)) => Ok(Some(file)), + Ok(Err(error)) => Err(policy_error(format!( + "attestation policy cache locking failed: {error}" + ))), + Err(error) => Err(policy_error(format!( + "attestation policy cache lock task failed: {error}" + ))), + } + } + + async fn load_cache(&self) -> Result { + let Some(path) = self.config.cache_path.clone() else { + return Ok(CachedRepository::default()); + }; + let repository_id = repository_id(&self.config.repository_url); + let result = tokio::task::spawn_blocking(move || read_cache(&path, &repository_id)).await; + match result { + Err(error) => Err(policy_error(format!( + "attestation policy cache read task failed: {error}" + ))), + Ok(Ok(cached)) => { + validate_cached_root_span(&self.config.tuf_root, &cached)?; + Ok(cached) + } + Ok(Err(error)) => Err(policy_error(format!( + "attestation policy cache read failed: {error}" + ))), + } + } + + async fn record_authenticated_observation( + &self, + store: Arc, + now: jiff::Timestamp, + cached_repository_high_water: Option<&RepositoryHighWater>, + cached_channel_high_water: BTreeMap, + cache_guard: &mut Option, + ) -> Result { + let observation = + capture_authenticated_observation(store, &self.config.tuf_root, now).await?; + let root_history = root_history_for_process_state( + &observation.entries, + &observation.repository_high_water, + )?; + let (repository_high_water, channel_high_water, observation_error) = { + let mut memory = self + .memory_state + .state + .lock() + .expect("repository memory-state mutex poisoned"); + // Merge the disk-derived and process-wide floors as one state. A + // channel sequence is meaningful only in the targets-authority + // epoch that authenticated it. + let base = align_security_high_water_states_to_observed( + cached_repository_high_water, + cached_channel_high_water, + memory.high_water.repository.as_ref(), + &memory.high_water.channels, + &observation.repository_high_water, + &observation.entries, + )?; + let repository_merge = merge_repository_observation( + base.repository.as_ref(), + &observation.repository_high_water, + ); + let mut channels = base.channels; + retain_channel_floors_for_authority( + &mut channels, + &repository_merge.high_water.targets_authority, + )?; + let mut observation_error = observation.error; + if let Some(error) = repository_merge.error { + observation_error.get_or_insert(error); + } + if repository_merge.accepted_through_targets { + for (environment, candidate) in observation.channel_high_water { + match enforce_channel_high_water(&candidate, channels.get(&environment)) { + Ok(()) => { + if let Some(merged) = + merge_high_water(channels.get(&environment), Some(&candidate))? + { + channels.insert(environment, merged); + } + } + Err(error) => { + // Never activate a lower/equivocated channel, but + // still journal independently safe repository/root + // advancement. + observation_error.get_or_insert(error); + } + } + } + } + let repository_high_water = repository_merge.high_water; + memory.high_water.repository = Some(repository_high_water.clone()); + memory.high_water.channels = channels.clone(); + memory.root_history = root_history; + (repository_high_water, channels, observation_error) + }; + + if let Some(cache_path) = self.config.cache_path.clone() { + let repository_id = repository_id(&self.config.repository_url); + let persisted_repository_high_water = repository_high_water.clone(); + let persisted_channel_high_water = channel_high_water.clone(); + let entries = observation.entries.clone(); + persist_cache_while_locked( + cache_guard, + cache_path, + repository_id, + persisted_repository_high_water, + persisted_channel_high_water, + entries, + "journal cache", + ) + .await?; + } + + Ok(RecordedObservation { + cache: CachedRepository { + repository_high_water: Some(repository_high_water), + channel_high_water, + entries: observation.entries, + }, + observation_error, + }) + } + + #[cfg(test)] + pub(crate) fn fixed_for_test(policy: TrustedReleasePolicy) -> Arc { + let config = TrustedReleaseConfig { + environment: policy.environment, + repository_url: "https://attestations.invalid/tuf/".to_string(), + tuf_root: Arc::from(EMBEDDED_TUF_ROOT), + cache_path: None, + }; + Arc::new(Self { + repository: HttpTufRepository::new(&config.repository_url, false).unwrap(), + config, + refresh_coordinator: Arc::new(Mutex::new(RefreshCoordinator::default())), + memory_state: Arc::new(RepositoryMemoryState::default()), + fixed_policy: Some(policy), + }) + } + + #[cfg(test)] + pub(crate) fn install_policy_floor_for_test(&self, policy: &TrustedReleasePolicy) { + let mut memory = self + .memory_state + .state + .lock() + .expect("repository memory-state mutex poisoned"); + memory.high_water.repository = Some(policy.repository_high_water.clone()); + memory + .high_water + .channels + .insert(policy.environment, CacheHighWater::from_policy(policy)); + } +} + +async fn coalesce_refresh( + coordinator: Arc>, + operation: F, +) -> Result +where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let (id, mut receiver, sender) = { + let mut state = coordinator.lock().await; + if let Some((id, receiver)) = &state.in_flight { + (*id, receiver.clone(), None) + } else { + state.next_id = state.next_id.wrapping_add(1); + let id = state.next_id; + let (sender, receiver) = watch::channel(None); + state.in_flight = Some((id, receiver.clone())); + (id, receiver, Some(sender)) + } + }; + + if let Some(sender) = sender { + let worker_coordinator = Arc::clone(&coordinator); + tokio::spawn(async move { + let result = operation().await; + let shared = match result { + Ok(policy) => Ok(policy), + Err(error) => Err(SharedRefreshError::from_error(&error)), + }; + let _ = sender.send(Some(shared)); + let mut state = worker_coordinator.lock().await; + if state + .in_flight + .as_ref() + .is_some_and(|(active, _)| *active == id) + { + state.in_flight = None; + } + }); + } + + loop { + if let Some(result) = receiver.borrow().clone() { + return result.map_err(SharedRefreshError::into_error); + } + if receiver.changed().await.is_err() { + let mut state = coordinator.lock().await; + if state + .in_flight + .as_ref() + .is_some_and(|(active, _)| *active == id) + { + state.in_flight = None; + } + return Err(policy_error( + "attestation policy refresh worker ended without a result", + )); + } + } +} + +#[derive(Clone, Debug)] +struct HttpTufRepository { + metadata_base: Url, + targets_base: Url, + client: Client, +} + +impl HttpTufRepository { + fn new(repository_url: &str, allow_test_loopback_http: bool) -> Result { + Self::new_with_timeout( + repository_url, + allow_test_loopback_http, + TUF_REQUEST_TIMEOUT, + ) + } + + fn new_with_timeout( + repository_url: &str, + allow_test_loopback_http: bool, + request_timeout: Duration, + ) -> Result { + let base = validate_repository_base(repository_url, allow_test_loopback_http)?; + let metadata_base = base + .join("metadata/") + .map_err(|error| policy_error(format!("invalid TUF metadata URL: {error}")))?; + let targets_base = base + .join("targets/") + .map_err(|error| policy_error(format!("invalid TUF targets URL: {error}")))?; + let client = Client::builder() + .redirect(RedirectPolicy::none()) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + // Reqwest's request deadline spans connection, response headers, + // and the complete streamed body. The read timeout alone only + // rejects an idle stream and would permit an indefinite slow drip. + .timeout(request_timeout) + .user_agent(concat!("opensecret-rust-sdk/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| policy_error(format!("could not build TUF HTTP client: {error}")))?; + Ok(Self { + metadata_base, + targets_base, + client, + }) + } + + async fn bounded_get( + &self, + url: Url, + max_length: u64, + ) -> sigstore_tuf::Result>> { + let response = self.client.get(url.clone()).send().await.map_err(|error| { + sigstore_tuf::Error::Transport(format!( + "{TUF_UNAVAILABLE_PREFIX}GET {url} failed: {error}" + )) + })?; + // TUF's missing-version sentinel is exact: only a 404 proves that the + // next root does not exist. Treating 403 as absence would let a mirror + // hide a published root rotation behind an authorization failure. + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + let status = response.status(); + let unavailable = status.is_server_error() + || matches!( + status, + StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS + ); + let prefix = if unavailable { + TUF_UNAVAILABLE_PREFIX + } else { + "" + }; + return Err(sigstore_tuf::Error::Transport(format!( + "{prefix}GET {url} returned status {status}" + ))); + } + if response + .content_length() + .is_some_and(|length| length > max_length) + { + return Err(sigstore_tuf::Error::Transport(format!( + "GET {url} exceeds maximum response length {max_length}" + ))); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| { + sigstore_tuf::Error::Transport(format!( + "{TUF_UNAVAILABLE_PREFIX}reading {url} failed: {error}" + )) + })?; + if body.len() as u64 + chunk.len() as u64 > max_length { + return Err(sigstore_tuf::Error::Transport(format!( + "GET {url} exceeds maximum response length {max_length}" + ))); + } + body.extend_from_slice(&chunk); + } + Ok(Some(body)) + } + + fn url(base: &Url, relative: &str) -> sigstore_tuf::Result { + validate_repository_path(relative).map_err(|error| { + sigstore_tuf::Error::Transport(format!("invalid repository path: {error}")) + })?; + base.join(relative) + .map_err(|error| sigstore_tuf::Error::Transport(format!("invalid URL: {error}"))) + } +} + +impl Repository for HttpTufRepository { + fn fetch_metadata<'a>(&'a self, name: &'a str, max_length: u64) -> FetchFuture<'a> { + Box::pin(async move { + let url = Self::url(&self.metadata_base, name)?; + self.bounded_get(url, max_length).await + }) + } + + fn fetch_target<'a>(&'a self, path: &'a str, max_length: u64) -> FetchFuture<'a> { + Box::pin(async move { + let url = Self::url(&self.targets_base, path)?; + self.bounded_get(url, max_length).await + }) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum RootCeilingProbeState { + #[default] + NotObserved, + ConfirmedAbsent, + Present, + Failed, +} + +#[derive(Clone)] +struct RootCeilingRepository { + inner: R, + sentinel_name: Option, + probe_state: Arc>, +} + +impl RootCeilingRepository { + fn new(inner: R, bootstrap_version: u64) -> Self { + let sentinel_name = bootstrap_version + .saturating_add(MAX_ROOT_TRANSITIONS) + .checked_add(1) + .map(|version| format!("{version}.root.json")); + Self { + inner, + sentinel_name, + probe_state: Arc::new(StdMutex::new(RootCeilingProbeState::NotObserved)), + } + } + + fn probe_state(&self) -> Arc> { + Arc::clone(&self.probe_state) + } +} + +impl Repository for RootCeilingRepository +where + R: Repository + 'static, +{ + fn fetch_metadata<'a>(&'a self, name: &'a str, max_length: u64) -> FetchFuture<'a> { + let is_ceiling_sentinel = self + .sentinel_name + .as_deref() + .is_some_and(|sentinel| sentinel == name); + let probe_state = Arc::clone(&self.probe_state); + Box::pin(async move { + let result = self.inner.fetch_metadata(name, max_length).await; + if is_ceiling_sentinel { + let state = match &result { + Ok(None) => RootCeilingProbeState::ConfirmedAbsent, + Ok(Some(_)) => RootCeilingProbeState::Present, + Err(_) => RootCeilingProbeState::Failed, + }; + *probe_state + .lock() + .expect("root-ceiling probe mutex poisoned") = state; + } + result + }) + } + + fn fetch_target<'a>(&'a self, path: &'a str, max_length: u64) -> FetchFuture<'a> { + self.inner.fetch_target(path, max_length) + } +} + +#[derive(Debug, Default)] +struct SnapshotStore { + entries: StdMutex>>, +} + +impl SnapshotStore { + fn from_entries(entries: BTreeMap>) -> Self { + Self { + entries: StdMutex::new(entries), + } + } + + fn entries(&self) -> BTreeMap> { + self.entries.lock().expect("cache mutex poisoned").clone() + } + + fn replace_entries(&self, entries: BTreeMap>) { + *self.entries.lock().expect("cache mutex poisoned") = entries; + } +} + +impl MetadataStore for SnapshotStore { + fn load(&self, name: &str) -> Option> { + self.entries + .lock() + .expect("cache mutex poisoned") + .get(name) + .cloned() + } + + fn store(&self, name: &str, bytes: &[u8]) -> sigstore_tuf::Result<()> { + validate_store_name(name)?; + self.entries + .lock() + .expect("cache mutex poisoned") + .insert(name.to_string(), bytes.to_vec()); + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CacheFile { + schema: String, + repository_id: String, + repository_high_water: RepositoryHighWater, + channel_high_water: BTreeMap, + entries: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MetadataHighWater { + version: u64, + sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + authority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + referenced_authority: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RepositoryHighWater { + root: MetadataHighWater, + root_authority: RoleAuthority, + timestamp_authority: RoleAuthority, + snapshot_authority: RoleAuthority, + targets_authority: RoleAuthority, + authority_history: AuthorityHistory, + timestamp: Option, + snapshot_descriptor: Option, + snapshot: Option, + targets_descriptor: Option, + targets: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RoleAuthority { + threshold: usize, + key_fingerprints: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthorityProvenance { + key_fingerprints: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthorityHistory { + root: Vec, + timestamp: Vec, + snapshot: Vec, + targets: Vec, +} + +impl AuthorityHistory { + fn from_authorities( + root: &RoleAuthority, + timestamp: &RoleAuthority, + snapshot: &RoleAuthority, + targets: &RoleAuthority, + ) -> Self { + Self { + root: root.key_fingerprints.clone(), + timestamp: timestamp.key_fingerprints.clone(), + snapshot: snapshot.key_fingerprints.clone(), + targets: targets.key_fingerprints.clone(), + } + } +} + +impl From<&RoleAuthority> for AuthorityProvenance { + fn from(authority: &RoleAuthority) -> Self { + Self { + key_fingerprints: authority.key_fingerprints.clone(), + } + } +} + +#[cfg(test)] +impl RepositoryHighWater { + fn for_test() -> Self { + let mark = MetadataHighWater { + version: 1, + sha256: "a".repeat(SHA256_HEX_LEN), + authority: None, + referenced_authority: None, + }; + let role_mark = |authority: &RoleAuthority| MetadataHighWater { + authority: Some(authority.into()), + ..mark.clone() + }; + let descriptor_mark = + |authority: &RoleAuthority, referenced_authority: &RoleAuthority| MetadataHighWater { + authority: Some(authority.into()), + referenced_authority: Some(referenced_authority.into()), + ..mark.clone() + }; + let timestamp_authority = RoleAuthority::for_test('b'); + let snapshot_authority = RoleAuthority::for_test('c'); + let targets_authority = RoleAuthority::for_test('d'); + let root_authority = RoleAuthority::for_test('e'); + Self { + root: mark.clone(), + root_authority: root_authority.clone(), + timestamp_authority: timestamp_authority.clone(), + snapshot_authority: snapshot_authority.clone(), + targets_authority: targets_authority.clone(), + authority_history: AuthorityHistory::from_authorities( + &root_authority, + ×tamp_authority, + &snapshot_authority, + &targets_authority, + ), + timestamp: Some(role_mark(×tamp_authority)), + snapshot_descriptor: Some(descriptor_mark(×tamp_authority, &snapshot_authority)), + snapshot: Some(role_mark(&snapshot_authority)), + targets_descriptor: Some(descriptor_mark(&snapshot_authority, &targets_authority)), + targets: Some(role_mark(&targets_authority)), + } + } +} + +#[cfg(test)] +impl RoleAuthority { + fn for_test(byte: char) -> Self { + Self { + threshold: 1, + key_fingerprints: vec![byte.to_string().repeat(SHA256_HEX_LEN)], + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CacheHighWater { + sequence: u64, + policy_id: String, + authority: AuthorityProvenance, +} + +impl CacheHighWater { + fn from_policy(policy: &TrustedReleasePolicy) -> Self { + Self { + sequence: policy.sequence, + policy_id: policy.policy_id.clone(), + authority: (&policy.repository_high_water.targets_authority).into(), + } + } +} + +#[cfg(test)] +impl CacheHighWater { + fn for_test(sequence: u64, policy_byte: char, authority_byte: char) -> Self { + Self { + sequence, + policy_id: policy_byte.to_string().repeat(SHA256_HEX_LEN), + authority: (&RoleAuthority::for_test(authority_byte)).into(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct CachedRepository { + repository_high_water: Option, + channel_high_water: BTreeMap, + entries: BTreeMap>, +} + +#[derive(Debug)] +struct RecordedObservation { + cache: CachedRepository, + observation_error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Channel { + schema: String, + environment: AttestationEnvironment, + sequence: u64, + sigstore_trusted_root_target: TargetReference, + active: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TargetReference { + path: String, + sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ActiveRelease { + manifest_target: String, + manifest_sha256: String, + bundle_target: String, + bundle_sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReleaseManifest { + schema: String, + component: String, + environment: AttestationEnvironment, + release: ManifestRelease, + source: ManifestSource, + artifact: ManifestArtifact, + measurements: ManifestMeasurements, + build: ManifestBuild, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestRelease { + version: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestSource { + uri: String, + path: String, + r#ref: String, + revision: ManifestRevision, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestRevision { + algorithm: String, + digest: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestArtifact { + name: String, + media_type: String, + size: u64, + digests: ManifestDigests, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestDigests { + sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestMeasurements { + algorithm: String, + required_pcrs: [u8; 3], + pcrs: ManifestPcrs, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestPcrs { + #[serde(rename = "0")] + pcr0: String, + #[serde(rename = "1")] + pcr1: String, + #[serde(rename = "2")] + pcr2: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ManifestBuild { + system: String, + builder_id: String, + derivation: String, + flake_lock_sha256: String, + run_uri: String, +} + +trait BundleVerifier: Send + Sync { + fn verify( + &self, + manifest_bytes: &[u8], + bundle_bytes: &[u8], + trusted_root_bytes: &[u8], + ) -> Result<()>; +} + +#[derive(Debug)] +enum RefreshFailure { + Unavailable(Error), + UnavailableAfterChannel(Error), + Security(Error), +} + +impl RefreshFailure { + fn error_ref(&self) -> &Error { + match self { + Self::Unavailable(error) + | Self::UnavailableAfterChannel(error) + | Self::Security(error) => error, + } + } + + fn into_error(self) -> Error { + match self { + Self::Unavailable(error) + | Self::UnavailableAfterChannel(error) + | Self::Security(error) => error, + } + } +} + +impl From for RefreshFailure { + fn from(error: Error) -> Self { + Self::Security(error) + } +} + +fn classify_tuf_error(context: &str, error: sigstore_tuf::Error) -> RefreshFailure { + let unavailable = matches!( + &error, + sigstore_tuf::Error::Transport(message) + if message.starts_with(TUF_UNAVAILABLE_PREFIX) || message.ends_with("not found") + ); + if unavailable { + RefreshFailure::Unavailable(Error::TrustedReleaseNetwork(format!("{context}: {error}"))) + } else { + RefreshFailure::Security(policy_error(format!("{context}: {error}"))) + } +} + +fn prevent_fallback_after_channel(error: RefreshFailure) -> RefreshFailure { + match error { + RefreshFailure::Unavailable(error) => RefreshFailure::UnavailableAfterChannel(error), + other => other, + } +} + +struct PortableBundleVerifier; + +fn parse_portable_bundle(bundle_json: &str) -> Result { + let mut value: Value = serde_json::from_str(bundle_json) + .map_err(|error| policy_error(format!("invalid Sigstore bundle: {error}")))?; + if value + .pointer("/messageSignature/messageDigest/algorithm") + .is_some_and(|algorithm| algorithm.as_str() != Some("SHA2_256")) + { + return Err(policy_error( + "Sigstore messageDigest algorithm must be exactly SHA2_256", + )); + } + let entries = value + .pointer_mut("/verificationMaterial/tlogEntries") + .and_then(Value::as_array_mut) + .ok_or_else(|| { + policy_error("Sigstore bundle v0.3 must contain exactly one transparency-log entry") + })?; + if entries.len() != 1 { + return Err(policy_error( + "Sigstore bundle v0.3 must contain exactly one transparency-log entry", + )); + } + // ProtoJSON parsers accept an int64 encoded as either a decimal string or + // JSON number, and `null` is equivalent to an unset scalar. Canonical Rekor + // v2 output omits its zero-valued integratedTime, while the pinned Sigstore + // type rejects the two equivalent explicit forms. Normalize only v2 null + // and numeric zero before parsing. TUF has already authenticated the exact + // original bundle bytes. + let entry = entries[0] + .as_object_mut() + .ok_or_else(|| policy_error("Sigstore transparency-log entry must be a JSON object"))?; + let kind_version = entry.get("kindVersion"); + let is_hashedrekord_v2 = kind_version + .and_then(|value| value.get("kind")) + .and_then(Value::as_str) + == Some("hashedrekord") + && kind_version + .and_then(|value| value.get("version")) + .and_then(Value::as_str) + == Some("0.0.2"); + let normalize_integrated_time = match entry.get("integratedTime") { + Some(Value::Null) if is_hashedrekord_v2 => true, + Some(Value::Number(number)) if is_hashedrekord_v2 => { + if number.as_i64() != Some(0) { + return Err(policy_error( + "Rekor v2 transparency-log entry integratedTime must be absent, null, or zero", + )); + } + true + } + _ => false, + }; + let normalized = if normalize_integrated_time { + entry.remove("integratedTime"); + Cow::Owned( + serde_json::to_string(&value) + .map_err(|error| policy_error(format!("invalid Sigstore bundle: {error}")))?, + ) + } else { + Cow::Borrowed(bundle_json) + }; + + Bundle::from_json(&normalized) + .map_err(|error| policy_error(format!("invalid Sigstore bundle: {error}"))) +} + +fn validate_portable_bundle_profile(bundle: &Bundle) -> Result<()> { + if bundle.media_type != "application/vnd.dev.sigstore.bundle.v0.3+json" { + return Err(policy_error( + "Sigstore bundle mediaType must be application/vnd.dev.sigstore.bundle.v0.3+json", + )); + } + let SignatureContent::MessageSignature(signature) = &bundle.content else { + return Err(policy_error( + "Sigstore bundle must contain a messageSignature", + )); + }; + let digest = signature + .message_digest + .as_ref() + .ok_or_else(|| policy_error("Sigstore messageSignature must contain a messageDigest"))?; + if digest.algorithm != HashAlgorithm::Sha2256 { + return Err(policy_error( + "Sigstore messageDigest algorithm must be exactly SHA2_256", + )); + } + if digest.digest.as_bytes().len() != 32 { + return Err(policy_error( + "Sigstore SHA2_256 messageDigest must contain exactly 32 bytes", + )); + } + let [entry] = bundle.verification_material.tlog_entries.as_slice() else { + return Err(policy_error( + "Sigstore bundle v0.3 must contain exactly one transparency-log entry", + )); + }; + if entry.kind_version.kind != "hashedrekord" { + return Err(policy_error( + "Sigstore bundle transparency-log entry must be hashedrekord", + )); + } + match entry.kind_version.version.as_str() { + "0.0.1" => { + if entry.integrated_time <= 0 { + return Err(policy_error( + "Rekor v1 transparency-log entry integratedTime must be positive", + )); + } + if entry.inclusion_promise.is_none() { + return Err(policy_error( + "Rekor v1 transparency-log entry must contain an inclusion promise", + )); + } + } + "0.0.2" => { + if entry.integrated_time != 0 { + return Err(policy_error( + "Rekor v2 transparency-log entry integratedTime must be absent, null, or zero", + )); + } + } + version => { + return Err(policy_error(format!( + "unsupported hashedrekord version '{version}' in Sigstore bundle" + ))); + } + } + if bundle + .verification_material + .timestamp_verification_data + .rfc3161_timestamps + .is_empty() + { + return Err(policy_error( + "Sigstore bundle must contain an RFC3161 timestamp", + )); + } + if entry + .inclusion_proof + .as_ref() + .is_none_or(|proof| proof.checkpoint.is_empty()) + { + return Err(policy_error( + "Sigstore transparency-log entry must contain a Merkle inclusion proof and signed checkpoint", + )); + } + Ok(()) +} + +impl BundleVerifier for PortableBundleVerifier { + fn verify( + &self, + manifest_bytes: &[u8], + bundle_bytes: &[u8], + trusted_root_bytes: &[u8], + ) -> Result<()> { + let trusted_root_json = std::str::from_utf8(trusted_root_bytes).map_err(|error| { + policy_error(format!("Sigstore trusted root is not UTF-8: {error}")) + })?; + let trusted_root = SigstoreTrustedRoot::from_json(trusted_root_json) + .map_err(|error| policy_error(format!("invalid Sigstore trusted root: {error}")))?; + let bundle_json = std::str::from_utf8(bundle_bytes) + .map_err(|error| policy_error(format!("Sigstore bundle is not UTF-8: {error}")))?; + let bundle = parse_portable_bundle(bundle_json)?; + validate_portable_bundle_profile(&bundle)?; + // TUF has already authorized the exact manifest, bundle, and trusted + // root bytes. Sigstore therefore supplies cryptographic provenance and + // transparency, while builder admission remains a promotion-time + // policy. Deliberately leave identity and issuer unconstrained here so + // repository, workflow, and CI-provider migrations do not brick + // already released clients. + Verifier::new(&trusted_root) + .verify(manifest_bytes, &bundle, &VerificationPolicy::default()) + .map_err(|error| policy_error(format!("Sigstore verification failed: {error}")))?; + Ok(()) + } +} + +async fn resolve_policy( + repository: R, + store: Arc, + root_bytes: &[u8], + environment: AttestationEnvironment, + now: jiff::Timestamp, + bundle_verifier: &dyn BundleVerifier, +) -> std::result::Result { + resolve_policy_with_final_time( + repository, + store, + root_bytes, + environment, + now, + bundle_verifier, + jiff::Timestamp::now, + ) + .await +} + +async fn resolve_policy_with_final_time( + repository: R, + store: Arc, + root_bytes: &[u8], + environment: AttestationEnvironment, + now: jiff::Timestamp, + bundle_verifier: &dyn BundleVerifier, + final_now: F, +) -> std::result::Result +where + R: Repository + 'static, + F: FnOnce() -> jiff::Timestamp, +{ + let config = tuf_updater_config(); + let store_handle = Arc::clone(&store); + let bootstrap = sigstore_tuf::TrustedMetadataSet::from_root(root_bytes).map_err(|error| { + RefreshFailure::Security(policy_error(format!("invalid embedded TUF root: {error}"))) + })?; + let repository = RootCeilingRepository::new(repository, bootstrap.root().version); + let root_ceiling_probe = repository.probe_state(); + let mut updater = Updater::new(repository, root_bytes) + .map_err(|error| { + RefreshFailure::Security(policy_error(format!("invalid embedded TUF root: {error}"))) + })? + .with_config(config) + .with_store(store); + if let Err(error) = updater.refresh(now).await { + let failure = classify_tuf_error("TUF metadata refresh failed", error); + let ceiling_probe_failed = *root_ceiling_probe + .lock() + .expect("root-ceiling probe mutex poisoned") + == RootCeilingProbeState::Failed; + return Err(if ceiling_probe_failed { + prevent_fallback_after_channel(failure) + } else { + failure + }); + } + enforce_timestamp_window(&updater, now)?; + + let channel_path = format!("channels/{}.json", environment.as_str()); + let channel_bytes = get_target(&mut updater, &channel_path, now) + .await + .map_err(prevent_fallback_after_channel)?; + enforce_target_size("channel", &channel_bytes, MAX_CHANNEL_BYTES)?; + let channel: Channel = parse_json("channel", &channel_bytes)?; + validate_channel(&channel, environment)?; + let policy_id = sha256_hex(&channel_bytes); + + if channel.active.is_empty() { + let valid_until = authorizing_metadata_valid_until(&updater, final_now())?; + let repository_high_water = + repository_high_water_from_updater(&updater, &store_handle, root_bytes)?; + prune_repository_cache(&updater, &store_handle, environment, &channel)?; + return Ok(TrustedReleasePolicy { + environment, + sequence: channel.sequence, + policy_id, + repository_high_water, + valid_until, + releases: Vec::new(), + }); + } + + let sigstore_root_bytes = get_bound_target( + &mut updater, + &channel.sigstore_trusted_root_target.path, + &channel.sigstore_trusted_root_target.sha256, + MAX_SIGSTORE_ROOT_BYTES, + now, + ) + .await + .map_err(prevent_fallback_after_channel)?; + + let mut releases = Vec::with_capacity(channel.active.len()); + let mut versions = HashSet::new(); + let mut tuples = HashSet::new(); + for active in &channel.active { + let version = validate_release_targets(active, environment)?; + if !versions.insert(version.clone()) { + return Err(policy_error(format!( + "channel contains duplicate active release '{version}'" + )) + .into()); + } + + let manifest_bytes = get_bound_target( + &mut updater, + &active.manifest_target, + &active.manifest_sha256, + MAX_MANIFEST_BYTES, + now, + ) + .await + .map_err(prevent_fallback_after_channel)?; + let bundle_bytes = get_bound_target( + &mut updater, + &active.bundle_target, + &active.bundle_sha256, + MAX_BUNDLE_BYTES as usize, + now, + ) + .await + .map_err(prevent_fallback_after_channel)?; + + // Parse for the release/PCR contract, but verify the Sigstore + // signature over the exact raw bytes rather than a reserialization. + let manifest: ReleaseManifest = parse_json("release manifest", &manifest_bytes)?; + validate_manifest(&manifest, &version, environment)?; + bundle_verifier.verify(&manifest_bytes, &bundle_bytes, &sigstore_root_bytes)?; + + let pcr0 = decode_pcr("measurements.pcrs.0", &manifest.measurements.pcrs.pcr0)?; + let pcr1 = decode_pcr("measurements.pcrs.1", &manifest.measurements.pcrs.pcr1)?; + let pcr2 = decode_pcr("measurements.pcrs.2", &manifest.measurements.pcrs.pcr2)?; + if !tuples.insert((pcr0, pcr1, pcr2)) { + return Err( + policy_error("two active releases contain the same PCR0/PCR1/PCR2 tuple").into(), + ); + } + releases.push(TrustedRelease { + version, + pcr0, + pcr1, + pcr2, + }); + } + + // Network, target download, parsing, and Sigstore verification may span a + // metadata-expiry boundary. Re-check every top-level role against a fresh + // clock reading immediately before the PCR authorization can escape. + let valid_until = authorizing_metadata_valid_until(&updater, final_now())?; + let repository_high_water = + repository_high_water_from_updater(&updater, &store_handle, root_bytes)?; + prune_repository_cache(&updater, &store_handle, environment, &channel)?; + + Ok(TrustedReleasePolicy { + environment, + sequence: channel.sequence, + policy_id, + repository_high_water, + valid_until, + releases, + }) +} + +fn tuf_updater_config() -> UpdaterConfig { + UpdaterConfig { + root_max_length: MAX_ROOT_BYTES, + timestamp_max_length: MAX_TIMESTAMP_BYTES, + snapshot_max_length: MAX_SNAPSHOT_BYTES, + targets_max_length: MAX_TARGETS_METADATA_BYTES, + target_max_length: MAX_BUNDLE_BYTES, + // sigstore-tuf treats this as the number of fetch iterations, including + // the final missing-next-root sentinel. Thirty-three iterations permit + // root 1 through root 33 (32 rotations) and fail if root 34 exists. + max_root_rotations: MAX_ROOT_TRANSITIONS + 1, + max_delegations: 16, + } +} + +fn authorizing_metadata_valid_until( + updater: &Updater, + now: jiff::Timestamp, +) -> Result { + let trusted = updater.trusted(); + let mut valid_until = validate_role_current("root", &trusted.root().expires, now)?; + let timestamp = trusted + .timestamp() + .ok_or_else(|| policy_error("TUF refresh did not produce timestamp metadata"))?; + valid_until = valid_until.min(validate_role_current("timestamp", ×tamp.expires, now)?); + let snapshot = trusted + .snapshot() + .ok_or_else(|| policy_error("TUF refresh did not produce snapshot metadata"))?; + valid_until = valid_until.min(validate_role_current("snapshot", &snapshot.expires, now)?); + let targets = trusted + .targets() + .ok_or_else(|| policy_error("TUF refresh did not produce targets metadata"))?; + Ok(valid_until.min(validate_role_current("targets", &targets.expires, now)?)) +} + +fn validate_role_current( + role: &str, + expires: &str, + now: jiff::Timestamp, +) -> Result { + let expires = expires + .parse::() + .map_err(|error| policy_error(format!("invalid TUF {role} expiry: {error}")))?; + if expires <= now { + return Err(policy_error(format!( + "TUF {role} metadata expired during attestation policy refresh" + ))); + } + Ok(expires) +} + +fn repository_high_water_from_updater( + updater: &Updater, + store: &SnapshotStore, + bootstrap_root: &[u8], +) -> Result { + let trusted = updater.trusted(); + let root_chain = authenticated_root_authority_history( + bootstrap_root, + &store.entries(), + trusted.root().version, + trusted.root_bytes(), + )?; + if let Some(error) = root_chain.error { + return Err(error); + } + let authority_history = root_chain.repository.authority_history; + let authorities = root_role_authorities(trusted.root())?; + let timestamp = trusted + .timestamp() + .ok_or_else(|| policy_error("TUF refresh did not produce timestamp metadata"))?; + let snapshot = trusted + .snapshot() + .ok_or_else(|| policy_error("TUF refresh did not produce snapshot metadata"))?; + let targets = trusted + .targets() + .ok_or_else(|| policy_error("TUF refresh did not produce targets metadata"))?; + Ok(RepositoryHighWater { + root: MetadataHighWater { + version: trusted.root().version, + sha256: signed_metadata_sha256("root", trusted.root_bytes())?, + authority: None, + referenced_authority: None, + }, + root_authority: authorities.root.clone(), + timestamp_authority: authorities.timestamp.clone(), + snapshot_authority: authorities.snapshot.clone(), + targets_authority: authorities.targets.clone(), + authority_history, + timestamp: Some(metadata_high_water( + store, + "timestamp.json", + timestamp.version, + &authorities.timestamp, + )?), + snapshot_descriptor: Some(metadata_descriptor_high_water( + "snapshot", + timestamp + .snapshot_meta() + .ok_or_else(|| policy_error("TUF timestamp metadata does not pin snapshot.json"))?, + &authorities.timestamp, + &authorities.snapshot, + )?), + snapshot: Some(metadata_high_water( + store, + "snapshot.json", + snapshot.version, + &authorities.snapshot, + )?), + targets_descriptor: Some(metadata_descriptor_high_water( + "targets", + snapshot + .meta + .get("targets.json") + .ok_or_else(|| policy_error("TUF snapshot metadata does not pin targets.json"))?, + &authorities.snapshot, + &authorities.targets, + )?), + targets: Some(metadata_high_water( + store, + "targets.json", + targets.version, + &authorities.targets, + )?), + }) +} + +struct AuthenticatedObservation { + repository_high_water: RepositoryHighWater, + channel_high_water: BTreeMap, + entries: BTreeMap>, + error: Option, +} + +async fn capture_authenticated_observation( + store: Arc, + root_bytes: &[u8], + now: jiff::Timestamp, +) -> Result { + let store_handle = Arc::clone(&store); + let mut updater = Updater::new(StoreRepository::new(Arc::clone(&store)), root_bytes) + .map_err(|error| policy_error(format!("invalid embedded TUF root: {error}")))? + .with_config(tuf_updater_config()) + .with_store(store); + + // A downstream failure is expected for a partial observation. The trusted + // set still contains every role that was successfully authenticated before + // the failure, which is precisely the monotonic journal we must retain. + let _ = updater.refresh(now).await; + let root_chain = authenticated_root_authority_history( + root_bytes, + &store_handle.entries(), + updater.trusted().root().version, + updater.trusted().root_bytes(), + )?; + if let Some(error) = root_chain.error { + let entries = retain_root_only_observation_prefix(&store_handle, &root_chain.repository)?; + return Ok(AuthenticatedObservation { + repository_high_water: root_chain.repository, + channel_high_water: BTreeMap::new(), + entries, + error: Some(error), + }); + } + let repository_high_water = partial_repository_high_water( + &updater, + &store_handle, + root_chain.repository.authority_history, + )?; + let channel_high_water = prune_observation_cache(&updater, &store_handle)?; + Ok(AuthenticatedObservation { + repository_high_water, + channel_high_water, + entries: store_handle.entries(), + error: None, + }) +} + +fn partial_repository_high_water( + updater: &Updater, + store: &SnapshotStore, + authority_history: AuthorityHistory, +) -> Result { + let trusted = updater.trusted(); + let authorities = root_role_authorities(trusted.root())?; + let timestamp = trusted + .timestamp() + .map(|metadata| { + metadata_high_water( + store, + "timestamp.json", + metadata.version, + &authorities.timestamp, + ) + }) + .transpose()?; + let snapshot_descriptor = trusted + .timestamp() + .and_then(|metadata| metadata.snapshot_meta()) + .map(|descriptor| { + metadata_descriptor_high_water( + "snapshot", + descriptor, + &authorities.timestamp, + &authorities.snapshot, + ) + }) + .transpose()?; + let snapshot = trusted + .snapshot() + .map(|metadata| { + metadata_high_water( + store, + "snapshot.json", + metadata.version, + &authorities.snapshot, + ) + }) + .transpose()?; + let targets_descriptor = trusted + .snapshot() + .and_then(|metadata| metadata.meta.get("targets.json")) + .map(|descriptor| { + metadata_descriptor_high_water( + "targets", + descriptor, + &authorities.snapshot, + &authorities.targets, + ) + }) + .transpose()?; + let targets = trusted + .targets() + .map(|metadata| { + metadata_high_water( + store, + "targets.json", + metadata.version, + &authorities.targets, + ) + }) + .transpose()?; + Ok(RepositoryHighWater { + root: MetadataHighWater { + version: trusted.root().version, + sha256: signed_metadata_sha256("root", trusted.root_bytes())?, + authority: None, + referenced_authority: None, + }, + authority_history, + root_authority: authorities.root, + timestamp_authority: authorities.timestamp, + snapshot_authority: authorities.snapshot, + targets_authority: authorities.targets, + timestamp, + snapshot_descriptor, + snapshot, + targets_descriptor, + targets, + }) +} + +struct RootRoleAuthorities { + root: RoleAuthority, + timestamp: RoleAuthority, + snapshot: RoleAuthority, + targets: RoleAuthority, +} + +fn root_role_authorities(root: &sigstore_tuf::Root) -> Result { + // sigstore-tuf 0.11 counts distinct declared key IDs toward a threshold. + // Reject duplicate aliases for the same normalized key material on every + // top-level role, including root itself, before trusting online-role epoch + // descriptors derived from this root. + let root_authority = root_role_authority(root, "root")?; + let authorities = RootRoleAuthorities { + root: root_authority.clone(), + timestamp: root_role_authority(root, "timestamp")?, + snapshot: root_role_authority(root, "snapshot")?, + targets: root_role_authority(root, "targets")?, + }; + + // The offline root authority is the recovery boundary for every online + // role. Reusing any normalized root key material for timestamp, snapshot, + // or targets would let compromise of routine publishing credentials also + // authorize root rotation. Online roles may intentionally share custody in + // the initial 1-of-1 deployment, but none may intersect the root role. + let online_key_material = authorities + .timestamp + .key_fingerprints + .iter() + .chain(&authorities.snapshot.key_fingerprints) + .chain(&authorities.targets.key_fingerprints) + .collect::>(); + if let Some(fingerprint) = root_authority + .key_fingerprints + .iter() + .find(|fingerprint| online_key_material.contains(fingerprint)) + { + return Err(policy_error(format!( + "TUF root role key material must be disjoint from all online roles (shared fingerprint {fingerprint})" + ))); + } + + Ok(authorities) +} + +fn root_role_authority(root: &sigstore_tuf::Root, role_name: &str) -> Result { + let role = root + .role(role_name) + .ok_or_else(|| policy_error(format!("TUF root is missing role '{role_name}'")))?; + let mut key_fingerprints = Vec::with_capacity(role.keyids.len()); + for key_id in &role.keyids { + let key = root.keys.get(key_id).ok_or_else(|| { + policy_error(format!( + "TUF root role '{role_name}' references unknown key '{key_id}'" + )) + })?; + let verification_key = key + .verification_key() + .map_err(|error| policy_error(format!("invalid TUF {role_name} key: {error}")))?; + // Fingerprint normalized public-key material, not the TUF key ID or + // signing-scheme label. Declared IDs are opaque and the same RSA/EC key + // can be re-declared under another compatible scheme; compromise + // recovery must still recognize it as the same authority. + key_fingerprints.push(key_custody_fingerprint( + &key.scheme, + verification_key.as_bytes(), + )?); + } + key_fingerprints.sort(); + if key_fingerprints.windows(2).any(|keys| keys[0] == keys[1]) { + return Err(policy_error(format!( + "TUF root role '{role_name}' authorizes duplicate aliases for the same key material" + ))); + } + let authority = RoleAuthority { + threshold: role.threshold, + key_fingerprints, + }; + validate_role_authority(role_name, &authority)?; + Ok(authority) +} + +fn key_custody_fingerprint(scheme: &str, key_bytes: &[u8]) -> Result { + let family = match scheme { + "ecdsa-sha2-nistp256" => b"ecdsa-p256".as_slice(), + "ecdsa-sha2-nistp384" => b"ecdsa-p384".as_slice(), + "ed25519" => b"ed25519".as_slice(), + "rsassa-pss-sha256" | "rsassa-pss-sha384" | "rsassa-pss-sha512" => b"rsa".as_slice(), + scheme => { + return Err(policy_error(format!( + "unsupported TUF key scheme '{scheme}' while fingerprinting authority" + ))) + } + }; + let mut digest = Sha256::new(); + digest.update(b"opensecret-tuf-key-custody-v1\0"); + digest.update(family); + digest.update([0]); + digest.update(key_bytes); + Ok(hex::encode(digest.finalize())) +} + +fn metadata_high_water( + store: &SnapshotStore, + name: &str, + version: u64, + authority: &RoleAuthority, +) -> Result { + let bytes = store + .load(name) + .ok_or_else(|| policy_error(format!("verified TUF cache is missing {name}")))?; + Ok(MetadataHighWater { + version, + sha256: signed_metadata_sha256(name, &bytes)?, + authority: Some(authority.into()), + referenced_authority: None, + }) +} + +fn metadata_descriptor_high_water( + role: &str, + descriptor: &sigstore_tuf::MetaFile, + authority: &RoleAuthority, + referenced_authority: &RoleAuthority, +) -> Result { + let value = serde_json::to_value(descriptor).map_err(|error| { + policy_error(format!("invalid TUF {role} descriptor metadata: {error}")) + })?; + let canonical = sigstore_tuf::canonical_json::to_canonical_bytes(&value).map_err(|error| { + policy_error(format!("invalid TUF {role} descriptor metadata: {error}")) + })?; + Ok(MetadataHighWater { + version: descriptor.version, + sha256: sha256_hex(&canonical), + authority: Some(authority.into()), + referenced_authority: Some(referenced_authority.into()), + }) +} + +fn signed_metadata_sha256(role: &str, envelope_bytes: &[u8]) -> Result { + let envelope: Value = serde_json::from_slice(envelope_bytes) + .map_err(|error| policy_error(format!("invalid TUF {role} envelope JSON: {error}")))?; + let signed = envelope.get("signed").ok_or_else(|| { + policy_error(format!( + "invalid TUF {role} envelope: missing signed payload" + )) + })?; + let canonical = sigstore_tuf::canonical_json::to_canonical_bytes(signed) + .map_err(|error| policy_error(format!("invalid TUF {role} signed metadata: {error}")))?; + Ok(sha256_hex(&canonical)) +} + +fn prune_repository_cache( + updater: &Updater, + store: &SnapshotStore, + current_environment: AttestationEnvironment, + current_channel: &Channel, +) -> Result<()> { + let existing = store.entries(); + let mut retained = BTreeMap::new(); + let trusted_root_version = updater.trusted().root().version; + + for (name, bytes) in &existing { + let Some(version) = name + .strip_prefix("root_history/") + .and_then(|name| name.strip_suffix(".root.json")) + .and_then(|version| version.parse::().ok()) + else { + continue; + }; + if version <= trusted_root_version { + retained.insert(name.clone(), bytes.clone()); + } + } + retained.insert( + "root.json".to_string(), + updater.trusted().root_bytes().to_vec(), + ); + for name in ["timestamp.json", "snapshot.json", "targets.json"] { + let bytes = existing + .get(name) + .ok_or_else(|| policy_error(format!("verified TUF cache is missing {name}")))?; + retained.insert(name.to_string(), bytes.clone()); + } + + retain_complete_channel(updater, current_environment, current_channel, &mut retained)?; + for environment in [ + AttestationEnvironment::Production, + AttestationEnvironment::Development, + ] { + if environment == current_environment { + continue; + } + let channel_path = format!("channels/{}.json", environment.as_str()); + let Some(channel_bytes) = cached_top_level_target(updater, &channel_path) else { + continue; + }; + let Ok(channel) = parse_json::("channel", &channel_bytes) else { + continue; + }; + if validate_channel(&channel, environment).is_err() { + continue; + } + let mut candidate = BTreeMap::new(); + if retain_complete_channel(updater, environment, &channel, &mut candidate).is_ok() { + retained.extend(candidate); + } + } + + if retained.len() > MAX_CACHE_ENTRIES + || retained.values().map(Vec::len).sum::() as u64 > MAX_CACHE_BYTES + { + return Err(policy_error( + "minimum verified TUF cache generation exceeds cache bounds", + )); + } + store.replace_entries(retained); + Ok(()) +} + +fn prune_observation_cache( + updater: &Updater, + store: &SnapshotStore, +) -> Result> { + let existing = store.entries(); + let mut retained = retain_observed_metadata(updater, &existing)?; + let mut channel_high_water = BTreeMap::new(); + if updater.trusted().targets().is_some() { + let targets_authority = root_role_authority(updater.trusted().root(), "targets")?; + for environment in [ + AttestationEnvironment::Production, + AttestationEnvironment::Development, + ] { + let channel_path = format!("channels/{}.json", environment.as_str()); + let Some(channel_bytes) = cached_top_level_target(updater, &channel_path) else { + continue; + }; + // A target may satisfy TUF's repository-wide 2 MiB cap while + // exceeding this channel schema's tighter 128 KiB cap. It cannot + // produce a channel floor, but it must not prevent already + // authenticated root/top-level metadata from being journaled. + if enforce_target_size("channel", &channel_bytes, MAX_CHANNEL_BYTES).is_err() { + continue; + } + let Ok(channel) = parse_json::("channel", &channel_bytes) else { + continue; + }; + if validate_channel(&channel, environment).is_err() { + continue; + } + channel_high_water.insert( + environment, + CacheHighWater { + sequence: channel.sequence, + policy_id: sha256_hex(&channel_bytes), + authority: (&targets_authority).into(), + }, + ); + let mut candidate = BTreeMap::new(); + if retain_complete_channel(updater, environment, &channel, &mut candidate).is_ok() { + retained.extend(candidate); + } else { + retained.insert(format!("targets/{channel_path}"), channel_bytes); + } + } + } + if retained.len() > MAX_CACHE_ENTRIES + || retained.values().map(Vec::len).sum::() as u64 > MAX_CACHE_BYTES + { + return Err(policy_error( + "minimum authenticated TUF observation exceeds cache bounds", + )); + } + store.replace_entries(retained); + Ok(channel_high_water) +} + +fn retain_observed_metadata( + updater: &Updater, + existing: &BTreeMap>, +) -> Result>> { + let mut retained = BTreeMap::new(); + let trusted = updater.trusted(); + for (name, bytes) in existing { + let Some(version) = name + .strip_prefix("root_history/") + .and_then(|name| name.strip_suffix(".root.json")) + .and_then(|version| version.parse::().ok()) + else { + continue; + }; + if version <= trusted.root().version { + retained.insert(name.clone(), bytes.clone()); + } + } + retained.insert("root.json".to_string(), trusted.root_bytes().to_vec()); + for (name, present) in [ + ("timestamp.json", trusted.timestamp().is_some()), + ("snapshot.json", trusted.snapshot().is_some()), + ("targets.json", trusted.targets().is_some()), + ] { + if present { + let bytes = existing + .get(name) + .ok_or_else(|| policy_error(format!("verified TUF cache is missing {name}")))?; + retained.insert(name.to_string(), bytes.clone()); + } + } + Ok(retained) +} + +fn retain_root_only_observation_prefix( + store: &SnapshotStore, + repository: &RepositoryHighWater, +) -> Result>> { + let existing = store.entries(); + let mut retained = BTreeMap::new(); + for (name, bytes) in &existing { + let Some(version) = name + .strip_prefix("root_history/") + .and_then(|name| name.strip_suffix(".root.json")) + .and_then(|version| version.parse::().ok()) + else { + continue; + }; + if version <= repository.root.version { + retained.insert(name.clone(), bytes.clone()); + } + } + let anchor_name = format!("root_history/{}.root.json", repository.root.version); + let anchor_bytes = retained.get(&anchor_name).ok_or_else(|| { + policy_error(format!( + "authenticated TUF root prefix is missing root version {}", + repository.root.version + )) + })?; + let anchor = root_transition_high_water(anchor_bytes)?; + if anchor.root != repository.root + || anchor.root_authority != repository.root_authority + || anchor.timestamp_authority != repository.timestamp_authority + || anchor.snapshot_authority != repository.snapshot_authority + || anchor.targets_authority != repository.targets_authority + { + return Err(policy_error( + "authenticated TUF root prefix does not match its repository floor", + )); + } + retained.insert("root.json".to_string(), anchor_bytes.clone()); + if retained.len() > MAX_CACHE_ENTRIES + || retained.values().map(Vec::len).sum::() as u64 > MAX_CACHE_BYTES + { + return Err(policy_error( + "minimum authenticated TUF root prefix exceeds cache bounds", + )); + } + store.replace_entries(retained.clone()); + Ok(retained) +} + +fn retain_complete_channel( + updater: &Updater, + environment: AttestationEnvironment, + channel: &Channel, + retained: &mut BTreeMap>, +) -> Result<()> { + let channel_path = format!("channels/{}.json", environment.as_str()); + retain_cached_target(updater, &channel_path, None, MAX_CHANNEL_BYTES, retained)?; + if channel.active.is_empty() { + return Ok(()); + } + retain_cached_target( + updater, + &channel.sigstore_trusted_root_target.path, + Some(&channel.sigstore_trusted_root_target.sha256), + MAX_SIGSTORE_ROOT_BYTES, + retained, + )?; + for active in &channel.active { + validate_release_targets(active, environment)?; + retain_cached_target( + updater, + &active.manifest_target, + Some(&active.manifest_sha256), + MAX_MANIFEST_BYTES, + retained, + )?; + retain_cached_target( + updater, + &active.bundle_target, + Some(&active.bundle_sha256), + MAX_BUNDLE_BYTES as usize, + retained, + )?; + } + Ok(()) +} + +fn retain_cached_target( + updater: &Updater, + path: &str, + expected_sha256: Option<&str>, + max_length: usize, + retained: &mut BTreeMap>, +) -> Result<()> { + let bytes = cached_top_level_target(updater, path).ok_or_else(|| { + policy_error(format!( + "verified TUF cache is missing current target '{path}'" + )) + })?; + enforce_target_size(path, &bytes, max_length)?; + if let Some(expected_sha256) = expected_sha256 { + validate_hex("target sha256", expected_sha256, SHA256_HEX_LEN)?; + if sha256_hex(&bytes) != expected_sha256 { + return Err(policy_error(format!( + "cached target '{path}' does not match its channel SHA-256" + ))); + } + } + retained.insert(format!("targets/{path}"), bytes); + Ok(()) +} + +fn cached_top_level_target(updater: &Updater, path: &str) -> Option> { + let target = updater.find_target(path)?; + updater.find_cached_target(target, path) +} + +async fn get_target( + updater: &mut Updater, + path: &str, + now: jiff::Timestamp, +) -> std::result::Result, RefreshFailure> { + validate_target_path(path)?; + if updater.find_target(path).is_none() { + return Err(policy_error(format!( + "required target '{path}' must be authorized by top-level targets metadata" + )) + .into()); + } + updater.get_target(path, now).await.map_err(|error| { + classify_tuf_error(&format!("TUF target '{path}' failed verification"), error) + }) +} + +async fn get_bound_target( + updater: &mut Updater, + path: &str, + expected_sha256: &str, + max_length: usize, + now: jiff::Timestamp, +) -> std::result::Result, RefreshFailure> { + validate_hex("target sha256", expected_sha256, SHA256_HEX_LEN)?; + let bytes = get_target(updater, path, now).await?; + enforce_target_size(path, &bytes, max_length)?; + let actual = sha256_hex(&bytes); + if actual != expected_sha256 { + return Err(policy_error(format!( + "channel SHA-256 for target '{path}' is '{expected_sha256}', got '{actual}'" + )) + .into()); + } + Ok(bytes) +} + +fn enforce_target_size(label: &str, bytes: &[u8], max_length: usize) -> Result<()> { + if bytes.len() > max_length { + return Err(policy_error(format!( + "authenticated {label} exceeds maximum length {max_length}" + ))); + } + Ok(()) +} + +fn enforce_timestamp_window(updater: &Updater, now: jiff::Timestamp) -> Result<()> { + let timestamp = updater + .trusted() + .timestamp() + .ok_or_else(|| policy_error("TUF refresh did not produce timestamp metadata"))?; + validate_timestamp_window(×tamp.expires, now) +} + +fn validate_timestamp_window(expires: &str, now: jiff::Timestamp) -> Result<()> { + let expires = expires + .parse::() + .map_err(|error| policy_error(format!("invalid TUF timestamp expiry: {error}")))?; + if expires < now { + return Err(policy_error("TUF timestamp metadata has expired")); + } + if expires.duration_since(now) > jiff::SignedDuration::from_hours(MAX_TIMESTAMP_VALIDITY_HOURS) + { + return Err(policy_error(format!( + "TUF timestamp validity exceeds the {MAX_TIMESTAMP_VALIDITY_HOURS}-hour last-known-good window" + ))); + } + Ok(()) +} + +fn validate_channel(channel: &Channel, environment: AttestationEnvironment) -> Result<()> { + if channel.schema != CHANNEL_SCHEMA { + return Err(policy_error(format!( + "unsupported channel schema '{}'", + channel.schema + ))); + } + if channel.environment != environment { + return Err(policy_error(format!( + "channel environment '{}' does not match requested '{}'", + channel.environment, environment + ))); + } + if channel.sequence == 0 { + return Err(policy_error("channel sequence must be greater than zero")); + } + if channel.active.len() > MAX_ACTIVE_RELEASES { + return Err(policy_error(format!( + "channel may contain at most {MAX_ACTIVE_RELEASES} active releases" + ))); + } + if channel.sigstore_trusted_root_target.path != "sigstore/trusted_root.json" { + return Err(policy_error( + "sigstoreTrustedRootTarget.path must be 'sigstore/trusted_root.json'", + )); + } + validate_hex( + "sigstoreTrustedRootTarget.sha256", + &channel.sigstore_trusted_root_target.sha256, + SHA256_HEX_LEN, + )?; + Ok(()) +} + +fn validate_release_targets( + active: &ActiveRelease, + environment: AttestationEnvironment, +) -> Result { + validate_hex("manifestSha256", &active.manifest_sha256, SHA256_HEX_LEN)?; + validate_hex("bundleSha256", &active.bundle_sha256, SHA256_HEX_LEN)?; + let manifest_version = + release_version_from_target(&active.manifest_target, environment, "manifest.json")?; + let bundle_version = + release_version_from_target(&active.bundle_target, environment, "manifest.sigstore.json")?; + if manifest_version != bundle_version { + return Err(policy_error( + "manifestTarget and bundleTarget identify different releases", + )); + } + Ok(manifest_version) +} + +fn release_version_from_target( + path: &str, + environment: AttestationEnvironment, + file: &str, +) -> Result { + validate_target_path(path)?; + let parts = path.split('/').collect::>(); + if parts.len() != 4 + || parts[0] != "releases" + || parts[2] != environment.as_str() + || parts[3] != file + { + return Err(policy_error(format!( + "release target '{path}' does not belong to the '{}' channel", + environment + ))); + } + validate_version(parts[1])?; + Ok(parts[1].to_string()) +} + +fn validate_manifest( + manifest: &ReleaseManifest, + version: &str, + environment: AttestationEnvironment, +) -> Result<()> { + if manifest.schema != MANIFEST_SCHEMA { + return Err(policy_error(format!( + "unsupported manifest schema '{}'", + manifest.schema + ))); + } + if manifest.component != COMPONENT { + return Err(policy_error(format!( + "manifest component must be '{COMPONENT}'" + ))); + } + if manifest.environment != environment { + return Err(policy_error(format!( + "manifest environment '{}' does not match channel '{}'", + manifest.environment, environment + ))); + } + validate_version(&manifest.release.version)?; + if manifest.release.version != version { + return Err(policy_error(format!( + "manifest release version '{}' does not match target path '{version}'", + manifest.release.version + ))); + } + + let expected_ref = format!("refs/tags/v{version}"); + if manifest.source.r#ref != expected_ref { + return Err(policy_error(format!( + "manifest source ref must be '{expected_ref}'" + ))); + } + if manifest.source.revision.algorithm != "git-sha1" { + return Err(policy_error( + "manifest source revision algorithm must be 'git-sha1'", + )); + } + validate_hex( + "source.revision.digest", + &manifest.source.revision.digest, + 40, + )?; + validate_source_path(&manifest.source.path)?; + validate_https_url("source.uri", &manifest.source.uri)?; + + validate_file_name("artifact.name", &manifest.artifact.name)?; + if manifest.artifact.media_type != EIF_MEDIA_TYPE { + return Err(policy_error(format!( + "manifest artifact mediaType must be '{EIF_MEDIA_TYPE}'" + ))); + } + if manifest.artifact.size == 0 { + return Err(policy_error( + "manifest artifact size must be greater than zero", + )); + } + validate_hex( + "artifact.digests.sha256", + &manifest.artifact.digests.sha256, + SHA256_HEX_LEN, + )?; + if manifest.measurements.algorithm != "sha384" { + return Err(policy_error( + "manifest measurements algorithm must be 'sha384'", + )); + } + if manifest.measurements.required_pcrs != [0, 1, 2] { + return Err(policy_error( + "manifest requiredPcrs must be exactly [0, 1, 2]", + )); + } + decode_pcr("measurements.pcrs.0", &manifest.measurements.pcrs.pcr0)?; + decode_pcr("measurements.pcrs.1", &manifest.measurements.pcrs.pcr1)?; + decode_pcr("measurements.pcrs.2", &manifest.measurements.pcrs.pcr2)?; + + if manifest.build.system != "nix" { + return Err(policy_error("manifest build system must be 'nix'")); + } + validate_identifier("build.builderId", &manifest.build.builder_id)?; + validate_nonempty("build.derivation", &manifest.build.derivation)?; + validate_hex( + "build.flakeLockSha256", + &manifest.build.flake_lock_sha256, + SHA256_HEX_LEN, + )?; + validate_https_url("build.runUri", &manifest.build.run_uri)?; + Ok(()) +} + +fn parse_json Deserialize<'de>>(label: &str, bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + .map_err(|error| policy_error(format!("invalid {label} JSON: {error}"))) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn validate_version(version: &str) -> Result<()> { + let parts = version.split('.').collect::>(); + if version.starts_with('v') + || parts.len() != 3 + || parts.iter().any(|part| { + part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + }) + { + return Err(policy_error(format!( + "release version '{version}' must be stable MAJOR.MINOR.PATCH without a leading v" + ))); + } + Ok(()) +} + +fn validate_identifier(field: &str, value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 256 + || !value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(policy_error(format!("{field} is not a valid identifier"))); + } + Ok(()) +} + +fn validate_nonempty(field: &str, value: &str) -> Result<()> { + if value.is_empty() || value.trim() != value || value.len() > 4_096 { + return Err(policy_error(format!( + "{field} must be a non-empty, trimmed string" + ))); + } + Ok(()) +} + +fn validate_hex(field: &str, value: &str, length: usize) -> Result<()> { + if value.len() != length + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(policy_error(format!( + "{field} must be exactly {length} lowercase hexadecimal characters" + ))); + } + Ok(()) +} + +fn decode_pcr(field: &str, value: &str) -> Result<[u8; SHA384_BYTES_LEN]> { + validate_hex(field, value, SHA384_HEX_LEN)?; + let bytes = hex::decode(value).map_err(|error| policy_error(format!("{field}: {error}")))?; + if bytes.iter().all(|byte| *byte == 0) { + return Err(policy_error(format!("{field} must not be all zeroes"))); + } + bytes + .try_into() + .map_err(|_| policy_error(format!("{field} must decode to 48 bytes"))) +} + +fn attestation_pcr(document: &AttestationDocument, index: usize) -> Result<[u8; 48]> { + document + .pcrs + .get(&index) + .ok_or_else(|| Error::AttestationVerificationFailed(format!("PCR{index} missing")))? + .as_slice() + .try_into() + .map_err(|_| { + Error::AttestationVerificationFailed(format!("PCR{index} must be exactly 48 bytes")) + }) +} + +fn validate_file_name(field: &str, value: &str) -> Result<()> { + validate_nonempty(field, value)?; + if matches!(value, "." | "..") || value.contains('/') || value.contains('\\') { + return Err(policy_error(format!("{field} must be a file name"))); + } + Ok(()) +} + +fn validate_source_path(value: &str) -> Result<()> { + validate_nonempty("source.path", value)?; + if value != "." + && (value.starts_with('/') + || value.contains('\\') + || value.split('/').any(|part| matches!(part, "." | ".." | ""))) + { + return Err(policy_error( + "source.path must be '.' or a safe repository-relative path", + )); + } + Ok(()) +} + +fn validate_https_url(field: &str, value: &str) -> Result { + let url = + Url::parse(value).map_err(|error| policy_error(format!("invalid {field} URL: {error}")))?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(policy_error(format!( + "{field} must be an HTTPS URL without credentials, query, or fragment" + ))); + } + Ok(url) +} + +fn validate_target_path(path: &str) -> Result<()> { + if path.is_empty() + || path.starts_with('/') + || path.contains('\\') + || path.contains('?') + || path.contains('#') + || path + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + return Err(policy_error(format!("unsafe TUF target path '{path}'"))); + } + Ok(()) +} + +fn validate_repository_path(path: &str) -> std::result::Result<(), &'static str> { + if path.is_empty() + || path.starts_with('/') + || path.contains('\\') + || path.contains('?') + || path.contains('#') + || path + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + Err("unsafe relative path") + } else { + Ok(()) + } +} + +fn validate_repository_base(value: &str, allow_test_loopback_http: bool) -> Result { + let with_slash = if value.ends_with('/') { + value.to_string() + } else { + format!("{value}/") + }; + let url = Url::parse(&with_slash) + .map_err(|error| policy_error(format!("invalid TUF repository URL: {error}")))?; + let loopback_http = allow_test_loopback_http + && url.scheme() == "http" + && url.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if (url.scheme() != "https" && !loopback_http) + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(policy_error( + "TUF repository must be an HTTPS URL without credentials, query, or fragment", + )); + } + Ok(url) +} + +fn validate_store_name(name: &str) -> sigstore_tuf::Result<()> { + if name.is_empty() + || name.starts_with('/') + || name.contains('\\') + || name + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + return Err(sigstore_tuf::Error::Malformed(format!( + "unsafe cache name '{name}'" + ))); + } + Ok(()) +} + +fn is_unpublished_root(bytes: &[u8]) -> bool { + serde_json::from_slice::(bytes) + .ok() + .and_then(|value| { + value + .get("schema") + .and_then(|schema| schema.as_str()) + .map(str::to_owned) + }) + .as_deref() + == Some(UNPUBLISHED_ROOT_SCHEMA) +} + +fn validate_official_embedded_root(bytes: &[u8]) -> Result<()> { + // Keep staging builds fail-closed at refresh time while the generated + // production bootstrap has not yet replaced the explicit placeholder. + if is_unpublished_root(bytes) { + return Ok(()); + } + let trusted = sigstore_tuf::TrustedMetadataSet::from_root(bytes) + .map_err(|error| policy_error(format!("invalid official embedded TUF root: {error}")))?; + if trusted.root().version != 1 { + return Err(policy_error(format!( + "official embedded TUF root signed version must be exactly 1; found {}", + trusted.root().version + ))); + } + Ok(()) +} + +fn validate_cached_root_span(bootstrap_root: &[u8], cached: &CachedRepository) -> Result<()> { + let Some(repository) = &cached.repository_high_water else { + return Ok(()); + }; + let bootstrap = root_transition_high_water(bootstrap_root)?; + let maximum_version = bootstrap.root.version.saturating_add(MAX_ROOT_TRANSITIONS); + if repository.root.version > maximum_version { + return Err(policy_error(format!( + "cached TUF root version {} exceeds the supported {MAX_ROOT_TRANSITIONS} transitions from bootstrap version {}", + repository.root.version, bootstrap.root.version + ))); + } + Ok(()) +} + +fn merge_high_water( + persisted: Option<&CacheHighWater>, + in_memory: Option<&CacheHighWater>, +) -> Result> { + match (persisted, in_memory) { + (None, None) => Ok(None), + (Some(value), None) | (None, Some(value)) => Ok(Some(value.clone())), + (Some(persisted), Some(in_memory)) => { + if persisted.sequence == in_memory.sequence + && persisted.policy_id != in_memory.policy_id + { + return Err(policy_error( + "conflicting channel policy IDs exist at the same high-water sequence", + )); + } + Ok(Some(if persisted.sequence == in_memory.sequence { + CacheHighWater { + sequence: persisted.sequence, + policy_id: persisted.policy_id.clone(), + authority: merge_authority_provenance( + &persisted.authority, + &in_memory.authority, + )?, + } + } else if persisted.sequence > in_memory.sequence { + persisted.clone() + } else { + in_memory.clone() + })) + } + } +} + +fn merge_channel_high_water_maps( + mut left: BTreeMap, + right: &BTreeMap, +) -> Result> { + for (environment, right_mark) in right { + if let Some(merged) = merge_high_water(left.get(environment), Some(right_mark))? { + left.insert(*environment, merged); + } + } + Ok(left) +} + +fn retain_channel_floors_for_authority( + channels: &mut BTreeMap, + candidate: &RoleAuthority, +) -> Result<()> { + let mut safely_replaced = Vec::new(); + for (environment, floor) in channels.iter_mut() { + if safely_replaces_authority(&floor.authority, candidate) { + safely_replaced.push(*environment); + continue; + } + // Signatures are detachable from a TUF envelope. When a root keeps + // enough old targets keys to authorize replay while adding keys, an + // equal channel may have hidden signatures from any newly authorized + // key. Conservatively bind the retained floor to the full current + // authority. Staged overlap is therefore not a recovery mechanism; + // recovery requires a replacement the old authority cannot satisfy. + floor.authority = union_authority_provenance(&floor.authority, candidate)?; + } + for environment in safely_replaced { + channels.remove(&environment); + } + Ok(()) +} + +fn merge_loaded_security_high_water_states( + cached_repository: Option<&RepositoryHighWater>, + cached_channels: BTreeMap, + memory_repository: Option<&RepositoryHighWater>, + memory_channels: &BTreeMap, + cached_entries: &BTreeMap>, + memory_root_history: &BTreeMap>, +) -> Result { + match (cached_repository, memory_repository) { + (Some(cached), Some(memory)) if cached.root.version > memory.root.version => { + // A process-local floor may be newer than the last successful disk + // write, while another process later writes a different forward + // root chain. Anchor and replay that exact cached chain from the + // in-memory root before combining any child/channel floors. + let advanced_memory = advance_security_floors_through_root_history( + Some(memory), + memory_channels.clone(), + cached, + cached_entries, + )?; + merge_security_high_water_states( + Some(cached), + cached_channels, + advanced_memory.repository.as_ref(), + &advanced_memory.channels, + ) + } + (Some(cached), Some(memory)) if memory.root.version > cached.root.version => { + let advanced_cache = advance_security_floors_through_root_history( + Some(cached), + cached_channels, + memory, + memory_root_history, + )?; + merge_security_high_water_states( + advanced_cache.repository.as_ref(), + advanced_cache.channels, + Some(memory), + memory_channels, + ) + } + _ => merge_security_high_water_states( + cached_repository, + cached_channels, + memory_repository, + memory_channels, + ), + } +} + +fn merge_security_high_water_states( + left_repository: Option<&RepositoryHighWater>, + left_channels: BTreeMap, + right_repository: Option<&RepositoryHighWater>, + right_channels: &BTreeMap, +) -> Result { + match (left_repository, right_repository) { + (None, None) => { + if !left_channels.is_empty() || !right_channels.is_empty() { + return Err(policy_error( + "channel high-water state exists without a TUF repository epoch", + )); + } + Ok(MemoryHighWater::default()) + } + (Some(repository), None) => { + if !right_channels.is_empty() { + return Err(policy_error( + "channel high-water state exists without a TUF repository epoch", + )); + } + Ok(MemoryHighWater { + repository: Some(repository.clone()), + channels: left_channels, + }) + } + (None, Some(repository)) => { + if !left_channels.is_empty() { + return Err(policy_error( + "channel high-water state exists without a TUF repository epoch", + )); + } + Ok(MemoryHighWater { + repository: Some(repository.clone()), + channels: right_channels.clone(), + }) + } + (Some(left), Some(right)) => { + let repository = merge_repository_high_waters(Some(left), Some(right))? + .expect("two repository high-water marks must merge to one"); + let channels = if left.root.version < right.root.version { + let mut older_channels = left_channels; + retain_channel_floors_for_authority(&mut older_channels, &right.targets_authority)?; + merge_channel_high_water_maps(older_channels, right_channels)? + } else if right.root.version < left.root.version { + let mut older_channels = right_channels.clone(); + retain_channel_floors_for_authority(&mut older_channels, &left.targets_authority)?; + merge_channel_high_water_maps(older_channels, &left_channels)? + } else { + return Ok(MemoryHighWater { + repository: Some(repository), + channels: merge_channel_high_water_maps(left_channels, right_channels)?, + }); + }; + Ok(MemoryHighWater { + repository: Some(repository), + channels, + }) + } + } +} + +fn advance_security_floors_through_root_history( + prior_repository: Option<&RepositoryHighWater>, + mut channels: BTreeMap, + observed_repository: &RepositoryHighWater, + entries: &BTreeMap>, +) -> Result { + validate_authenticated_root_history(entries, observed_repository.root.version)?; + let Some(prior_repository) = prior_repository else { + if !channels.is_empty() { + return Err(policy_error( + "channel high-water state exists without a TUF repository epoch", + )); + } + return Ok(MemoryHighWater { + repository: None, + channels, + }); + }; + if observed_repository.root.version < prior_repository.root.version { + return Err(policy_error(format!( + "TUF root metadata rollback: previously accepted version {}, received {}", + prior_repository.root.version, observed_repository.root.version + ))); + } + if observed_repository.root.version == prior_repository.root.version { + enforce_metadata_high_water( + "root", + Some(&observed_repository.root), + Some(&prior_repository.root), + )?; + authority_resets(prior_repository, observed_repository)?; + return Ok(MemoryHighWater { + repository: Some(prior_repository.clone()), + channels, + }); + } + + let anchor_name = format!("root_history/{}.root.json", prior_repository.root.version); + let anchor_bytes = entries.get(&anchor_name).ok_or_else(|| { + policy_error(format!( + "verified TUF cache is missing root-history anchor {}", + prior_repository.root.version + )) + })?; + let anchor = root_transition_high_water(anchor_bytes)?; + if anchor.root != prior_repository.root + || anchor.root_authority != prior_repository.root_authority + || anchor.timestamp_authority != prior_repository.timestamp_authority + || anchor.snapshot_authority != prior_repository.snapshot_authority + || anchor.targets_authority != prior_repository.targets_authority + { + return Err(policy_error(format!( + "authenticated TUF root history forks from in-memory root version {}", + prior_repository.root.version + ))); + } + let mut trusted_chain = sigstore_tuf::TrustedMetadataSet::from_root(anchor_bytes) + .map_err(|error| policy_error(format!("invalid TUF root-history anchor: {error}")))?; + + let mut repository = prior_repository.clone(); + for version in (prior_repository.root.version + 1)..=observed_repository.root.version { + let name = format!("root_history/{version}.root.json"); + let bytes = entries.get(&name).ok_or_else(|| { + policy_error(format!( + "verified TUF cache is missing authenticated root transition {version}" + )) + })?; + let transition = root_transition_high_water(bytes)?; + if transition.root.version != version { + return Err(policy_error(format!( + "TUF root history entry {version} contains version {}", + transition.root.version + ))); + } + trusted_chain.update_root(bytes).map_err(|error| { + policy_error(format!( + "TUF root transition {version} is not authenticated by the preceding root: {error}" + )) + })?; + let merged = merge_repository_observation(Some(&repository), &transition); + if let Some(error) = merged.error { + return Err(error); + } + repository = merged.high_water; + retain_channel_floors_for_authority(&mut channels, &repository.targets_authority)?; + } + + if repository.root != observed_repository.root + || repository.root_authority != observed_repository.root_authority + || repository.timestamp_authority != observed_repository.timestamp_authority + || repository.snapshot_authority != observed_repository.snapshot_authority + || repository.targets_authority != observed_repository.targets_authority + { + return Err(policy_error( + "authenticated TUF root history does not match the final trusted root", + )); + } + Ok(MemoryHighWater { + repository: Some(repository), + channels, + }) +} + +fn root_history_for_process_state( + entries: &BTreeMap>, + repository: &RepositoryHighWater, +) -> Result>> { + validate_authenticated_root_history(entries, repository.root.version)?; + let mut history = BTreeMap::new(); + for (name, bytes) in entries { + let Some(version) = name + .strip_prefix("root_history/") + .and_then(|name| name.strip_suffix(".root.json")) + .and_then(|version| version.parse::().ok()) + else { + continue; + }; + if version <= repository.root.version { + history.insert(name.clone(), bytes.clone()); + } + } + let anchor_name = format!("root_history/{}.root.json", repository.root.version); + let anchor = history.get(&anchor_name).ok_or_else(|| { + policy_error(format!( + "verified TUF state is missing root-history anchor {}", + repository.root.version + )) + })?; + let anchor = root_transition_high_water(anchor)?; + if anchor.root != repository.root + || anchor.root_authority != repository.root_authority + || anchor.timestamp_authority != repository.timestamp_authority + || anchor.snapshot_authority != repository.snapshot_authority + || anchor.targets_authority != repository.targets_authority + { + return Err(policy_error( + "verified TUF state root-history anchor does not match its repository floor", + )); + } + Ok(history) +} + +fn align_security_high_water_states_to_observed( + left_repository: Option<&RepositoryHighWater>, + left_channels: BTreeMap, + right_repository: Option<&RepositoryHighWater>, + right_channels: &BTreeMap, + observed_repository: &RepositoryHighWater, + entries: &BTreeMap>, +) -> Result { + let left = advance_security_floors_through_root_history( + left_repository, + left_channels, + observed_repository, + entries, + )?; + let right = advance_security_floors_through_root_history( + right_repository, + right_channels.clone(), + observed_repository, + entries, + )?; + merge_security_high_water_states( + left.repository.as_ref(), + left.channels, + right.repository.as_ref(), + &right.channels, + ) +} + +fn validate_authenticated_root_history( + entries: &BTreeMap>, + final_version: u64, +) -> Result<()> { + for (name, bytes) in entries { + let Some(version) = name + .strip_prefix("root_history/") + .and_then(|name| name.strip_suffix(".root.json")) + .and_then(|version| version.parse::().ok()) + else { + continue; + }; + if version > final_version { + continue; + } + let root = root_transition_high_water(bytes)?; + if root.root.version != version { + return Err(policy_error(format!( + "TUF root history entry {version} contains version {}", + root.root.version + ))); + } + } + Ok(()) +} + +struct AuthenticatedRootChain { + repository: RepositoryHighWater, + error: Option, +} + +fn authenticated_root_authority_history( + bootstrap_root: &[u8], + entries: &BTreeMap>, + final_version: u64, + final_root: &[u8], +) -> Result { + // A first-ever refresh has no persisted repository floor to advance, but + // it can still traverse several authenticated roots. Replay that exact + // chain from the embedded bootstrap so online keys retired by an + // intermediate root cannot be reintroduced after the first cache write. + let mut repository = root_transition_high_water(bootstrap_root)?; + let bootstrap_version = repository.root.version; + if final_version < repository.root.version { + return Err(policy_error(format!( + "final TUF root version {final_version} predates embedded bootstrap version {}", + repository.root.version + ))); + } + + let anchor_name = format!("root_history/{}.root.json", repository.root.version); + let anchor_bytes = entries.get(&anchor_name).ok_or_else(|| { + policy_error(format!( + "verified TUF cache is missing embedded root-history anchor {}", + repository.root.version + )) + })?; + let anchor = root_transition_high_water(anchor_bytes)?; + if anchor.root != repository.root + || anchor.root_authority != repository.root_authority + || anchor.timestamp_authority != repository.timestamp_authority + || anchor.snapshot_authority != repository.snapshot_authority + || anchor.targets_authority != repository.targets_authority + { + return Err(policy_error(format!( + "verified TUF root history forks from embedded bootstrap version {}", + repository.root.version + ))); + } + + let maximum_version = bootstrap_version.saturating_add(MAX_ROOT_TRANSITIONS); + let accepted_final_version = final_version.min(maximum_version); + let mut trusted_chain = sigstore_tuf::TrustedMetadataSet::from_root(bootstrap_root) + .map_err(|error| policy_error(format!("invalid embedded TUF root: {error}")))?; + if accepted_final_version > repository.root.version { + let first = repository.root.version.checked_add(1).ok_or_else(|| { + policy_error("embedded TUF root version cannot advance beyond u64::MAX") + })?; + for version in first..=accepted_final_version { + let name = format!("root_history/{version}.root.json"); + let Some(bytes) = entries.get(&name) else { + return Ok(AuthenticatedRootChain { + repository, + error: Some(policy_error(format!( + "verified TUF cache is missing authenticated root transition {version}" + ))), + }); + }; + let transition = match root_transition_high_water(bytes) { + Ok(transition) => transition, + Err(error) => { + return Ok(AuthenticatedRootChain { + repository, + error: Some(error), + }) + } + }; + if transition.root.version != version { + return Ok(AuthenticatedRootChain { + repository, + error: Some(policy_error(format!( + "TUF root history entry {version} contains version {}", + transition.root.version + ))), + }); + } + if let Err(error) = trusted_chain.update_root(bytes) { + return Ok(AuthenticatedRootChain { + repository, + error: Some(policy_error(format!( + "TUF root transition {version} is not authenticated by the preceding root: {error}" + ))), + }); + } + let merged = merge_repository_observation(Some(&repository), &transition); + if let Some(error) = merged.error { + return Ok(AuthenticatedRootChain { + repository, + error: Some(error), + }); + } + repository = merged.high_water; + } + } + + if final_version > maximum_version { + return Ok(AuthenticatedRootChain { + repository, + error: Some(policy_error(format!( + "TUF root chain exceeds the supported {MAX_ROOT_TRANSITIONS} transitions from bootstrap version {}", + bootstrap_version + ))), + }); + } + + let expected = root_transition_high_water(final_root)?; + if repository.root != expected.root + || repository.root_authority != expected.root_authority + || repository.timestamp_authority != expected.timestamp_authority + || repository.snapshot_authority != expected.snapshot_authority + || repository.targets_authority != expected.targets_authority + || trusted_chain.root().version != final_version + { + return Err(policy_error( + "authenticated TUF root history does not match the final trusted root", + )); + } + Ok(AuthenticatedRootChain { + repository, + error: None, + }) +} + +fn root_transition_high_water(root_bytes: &[u8]) -> Result { + let trusted = sigstore_tuf::TrustedMetadataSet::from_root(root_bytes) + .map_err(|error| policy_error(format!("invalid authenticated TUF root: {error}")))?; + let authorities = root_role_authorities(trusted.root())?; + Ok(RepositoryHighWater { + root: MetadataHighWater { + version: trusted.root().version, + sha256: signed_metadata_sha256("root", trusted.root_bytes())?, + authority: None, + referenced_authority: None, + }, + authority_history: AuthorityHistory::from_authorities( + &authorities.root, + &authorities.timestamp, + &authorities.snapshot, + &authorities.targets, + ), + root_authority: authorities.root, + timestamp_authority: authorities.timestamp, + snapshot_authority: authorities.snapshot, + targets_authority: authorities.targets, + timestamp: None, + snapshot_descriptor: None, + snapshot: None, + targets_descriptor: None, + targets: None, + }) +} + +fn enforce_high_water( + candidate: &TrustedReleasePolicy, + high_water: Option<&CacheHighWater>, +) -> Result<()> { + enforce_channel_high_water(&CacheHighWater::from_policy(candidate), high_water) +} + +fn enforce_channel_high_water( + candidate: &CacheHighWater, + high_water: Option<&CacheHighWater>, +) -> Result<()> { + let Some(high_water) = high_water else { + return Ok(()); + }; + if candidate.sequence < high_water.sequence { + return Err(policy_error(format!( + "channel sequence rollback: previously accepted {}, received {}", + high_water.sequence, candidate.sequence + ))); + } + if candidate.sequence == high_water.sequence && candidate.policy_id != high_water.policy_id { + return Err(policy_error(format!( + "channel changed without incrementing sequence {}", + candidate.sequence + ))); + } + Ok(()) +} + +fn enforce_repository_high_water( + candidate: &RepositoryHighWater, + high_water: Option<&RepositoryHighWater>, +) -> Result<()> { + let Some(high_water) = high_water else { + return Ok(()); + }; + enforce_metadata_high_water("root", Some(&candidate.root), Some(&high_water.root))?; + let resets = authority_resets(high_water, candidate)?; + if !resets.timestamp { + enforce_metadata_high_water( + "timestamp", + candidate.timestamp.as_ref(), + high_water.timestamp.as_ref(), + )?; + } + if !resets.snapshot_descriptor { + enforce_metadata_high_water( + "snapshot descriptor", + candidate.snapshot_descriptor.as_ref(), + high_water.snapshot_descriptor.as_ref(), + )?; + } + if !resets.snapshot { + enforce_metadata_high_water( + "snapshot", + candidate.snapshot.as_ref(), + high_water.snapshot.as_ref(), + )?; + } + if !resets.targets_descriptor { + enforce_metadata_high_water( + "targets descriptor", + candidate.targets_descriptor.as_ref(), + high_water.targets_descriptor.as_ref(), + )?; + } + if !resets.targets { + enforce_metadata_high_water( + "targets", + candidate.targets.as_ref(), + high_water.targets.as_ref(), + )?; + } + Ok(()) +} + +#[derive(Clone, Copy, Default)] +struct AuthorityResets { + timestamp: bool, + snapshot_descriptor: bool, + snapshot: bool, + targets_descriptor: bool, + targets: bool, +} + +fn authority_resets( + prior: &RepositoryHighWater, + candidate: &RepositoryHighWater, +) -> Result { + if candidate.root.version < prior.root.version { + return Err(policy_error(format!( + "TUF root metadata rollback: previously accepted version {}, received {}", + prior.root.version, candidate.root.version + ))); + } + if candidate.root.version == prior.root.version { + if candidate.root.sha256 != prior.root.sha256 { + return Err(policy_error(format!( + "TUF root metadata changed without incrementing version {}", + candidate.root.version + ))); + } + if candidate.root_authority != prior.root_authority + || candidate.timestamp_authority != prior.timestamp_authority + || candidate.snapshot_authority != prior.snapshot_authority + || candidate.targets_authority != prior.targets_authority + { + return Err(policy_error( + "TUF role authority changed without a new root version", + )); + } + return Ok(AuthorityResets::default()); + } + + // An authority descriptor changing is not sufficient to clear a rollback + // floor. During an overlap rotation, old keys may remain authorized by the + // new root and can replay the very metadata that established that floor. + // Reset only when the old key set cannot satisfy the candidate threshold. + let timestamp_changed = role_floor_is_safely_replaced( + "timestamp", + prior.timestamp.as_ref(), + &candidate.timestamp_authority, + )?; + let snapshot_descriptor_changed = descriptor_floor_is_safely_replaced( + "snapshot descriptor", + prior.snapshot_descriptor.as_ref(), + &candidate.timestamp_authority, + &candidate.snapshot_authority, + )?; + let snapshot_changed = role_floor_is_safely_replaced( + "snapshot", + prior.snapshot.as_ref(), + &candidate.snapshot_authority, + )?; + let targets_descriptor_changed = descriptor_floor_is_safely_replaced( + "targets descriptor", + prior.targets_descriptor.as_ref(), + &candidate.snapshot_authority, + &candidate.targets_authority, + )?; + let targets_changed = role_floor_is_safely_replaced( + "targets", + prior.targets.as_ref(), + &candidate.targets_authority, + )?; + Ok(AuthorityResets { + timestamp: timestamp_changed, + snapshot_descriptor: snapshot_descriptor_changed, + snapshot: snapshot_changed, + targets_descriptor: targets_descriptor_changed, + targets: targets_changed, + }) +} + +fn role_floor_is_safely_replaced( + role: &str, + floor: Option<&MetadataHighWater>, + candidate: &RoleAuthority, +) -> Result { + let Some(floor) = floor else { + return Ok(false); + }; + let authority = floor.authority.as_ref().ok_or_else(|| { + policy_error(format!( + "TUF {role} metadata floor is missing authority provenance" + )) + })?; + Ok(safely_replaces_authority(authority, candidate)) +} + +fn descriptor_floor_is_safely_replaced( + role: &str, + floor: Option<&MetadataHighWater>, + candidate_parent: &RoleAuthority, + candidate_child: &RoleAuthority, +) -> Result { + let Some(floor) = floor else { + return Ok(false); + }; + let parent = floor.authority.as_ref().ok_or_else(|| { + policy_error(format!( + "TUF {role} floor is missing asserting-authority provenance" + )) + })?; + let child = floor.referenced_authority.as_ref().ok_or_else(|| { + policy_error(format!( + "TUF {role} floor is missing referenced-authority provenance" + )) + })?; + Ok(safely_replaces_authority(parent, candidate_parent) + || safely_replaces_authority(child, candidate_child)) +} + +fn safely_replaces_authority(prior: &AuthorityProvenance, candidate: &RoleAuthority) -> bool { + let candidate_keys = candidate + .key_fingerprints + .iter() + .map(String::as_str) + .collect::>(); + let overlap = prior + .key_fingerprints + .iter() + .filter(|key| candidate_keys.contains(key.as_str())) + .count(); + overlap < candidate.threshold +} + +fn union_authority_provenance( + prior: &AuthorityProvenance, + candidate: &RoleAuthority, +) -> Result { + let mut key_fingerprints = prior.key_fingerprints.clone(); + key_fingerprints.extend(candidate.key_fingerprints.iter().cloned()); + key_fingerprints.sort(); + key_fingerprints.dedup(); + let provenance = AuthorityProvenance { key_fingerprints }; + validate_authority_provenance("cumulative", &provenance)?; + Ok(provenance) +} + +fn merge_authority_provenance( + left: &AuthorityProvenance, + right: &AuthorityProvenance, +) -> Result { + let mut key_fingerprints = left.key_fingerprints.clone(); + key_fingerprints.extend(right.key_fingerprints.iter().cloned()); + key_fingerprints.sort(); + key_fingerprints.dedup(); + let provenance = AuthorityProvenance { key_fingerprints }; + validate_authority_provenance("merged", &provenance)?; + Ok(provenance) +} + +fn merge_authority_histories( + left: &AuthorityHistory, + right: &AuthorityHistory, +) -> Result { + let history = AuthorityHistory { + root: merge_authority_key_history("root", &left.root, &right.root)?, + timestamp: merge_authority_key_history("timestamp", &left.timestamp, &right.timestamp)?, + snapshot: merge_authority_key_history("snapshot", &left.snapshot, &right.snapshot)?, + targets: merge_authority_key_history("targets", &left.targets, &right.targets)?, + }; + validate_authority_custody_classes(&history)?; + Ok(history) +} + +fn advance_authority_history( + prior: &RepositoryHighWater, + observed: &RepositoryHighWater, +) -> Result { + let prior_global = prior + .authority_history + .timestamp + .iter() + .chain(&prior.authority_history.snapshot) + .chain(&prior.authority_history.targets) + .map(String::as_str) + .collect::>(); + let history = AuthorityHistory { + root: merge_authority_key_history( + "root", + &prior.authority_history.root, + &observed.authority_history.root, + )?, + timestamp: advance_role_key_history( + "timestamp", + &prior.timestamp_authority, + &prior.authority_history.timestamp, + &prior_global, + &observed.timestamp_authority, + &observed.authority_history.timestamp, + )?, + snapshot: advance_role_key_history( + "snapshot", + &prior.snapshot_authority, + &prior.authority_history.snapshot, + &prior_global, + &observed.snapshot_authority, + &observed.authority_history.snapshot, + )?, + targets: advance_role_key_history( + "targets", + &prior.targets_authority, + &prior.authority_history.targets, + &prior_global, + &observed.targets_authority, + &observed.authority_history.targets, + )?, + }; + validate_authority_custody_classes(&history)?; + Ok(history) +} + +fn validate_authority_custody_classes(history: &AuthorityHistory) -> Result<()> { + let root = history + .root + .iter() + .map(String::as_str) + .collect::>(); + let online = history + .timestamp + .iter() + .chain(&history.snapshot) + .chain(&history.targets) + .map(String::as_str) + .collect::>(); + if let Some(fingerprint) = root.intersection(&online).next() { + return Err(policy_error(format!( + "TUF key custody class violation: root and online authority histories share key material {fingerprint}" + ))); + } + Ok(()) +} + +fn advance_role_key_history( + role: &str, + prior_authority: &RoleAuthority, + prior_history: &[String], + prior_global_history: &HashSet<&str>, + observed_authority: &RoleAuthority, + observed_history: &[String], +) -> Result> { + let prior_current = prior_authority + .key_fingerprints + .iter() + .map(String::as_str) + .collect::>(); + if let Some(reintroduced) = observed_authority.key_fingerprints.iter().find(|key| { + prior_global_history.contains(key.as_str()) && !prior_current.contains(key.as_str()) + }) { + return Err(policy_error(format!( + "TUF {role} authority reauthorizes retired key material {reintroduced}" + ))); + } + merge_authority_key_history(role, prior_history, observed_history) +} + +fn merge_authority_key_history( + role: &str, + left: &[String], + right: &[String], +) -> Result> { + let mut history = left.to_vec(); + history.extend(right.iter().cloned()); + history.sort(); + history.dedup(); + validate_authority_key_history(role, &history)?; + Ok(history) +} + +fn validate_authority_key_history(role: &str, history: &[String]) -> Result<()> { + if history.is_empty() || history.len() > MAX_AUTHORITY_KEYS { + return Err(policy_error(format!( + "TUF {role} authority history must contain between 1 and {MAX_AUTHORITY_KEYS} keys" + ))); + } + let mut prior = None; + for fingerprint in history { + validate_hex( + &format!("TUF {role} authority history fingerprint"), + fingerprint, + SHA256_HEX_LEN, + )?; + if prior.is_some_and(|prior: &String| prior >= fingerprint) { + return Err(policy_error(format!( + "TUF {role} authority history fingerprints must be unique and sorted" + ))); + } + prior = Some(fingerprint); + } + Ok(()) +} + +fn merge_repository_high_waters( + left: Option<&RepositoryHighWater>, + right: Option<&RepositoryHighWater>, +) -> Result> { + match (left, right) { + (None, None) => Ok(None), + (Some(value), None) | (None, Some(value)) => Ok(Some(value.clone())), + (Some(left), Some(right)) if left.root.version == right.root.version => { + enforce_metadata_high_water("root", Some(&left.root), Some(&right.root))?; + enforce_metadata_high_water("root", Some(&right.root), Some(&left.root))?; + authority_resets(left, right)?; + Ok(Some(RepositoryHighWater { + root: left.root.clone(), + root_authority: left.root_authority.clone(), + timestamp_authority: left.timestamp_authority.clone(), + snapshot_authority: left.snapshot_authority.clone(), + targets_authority: left.targets_authority.clone(), + authority_history: merge_authority_histories( + &left.authority_history, + &right.authority_history, + )?, + timestamp: merge_metadata_high_waters( + "timestamp", + left.timestamp.as_ref(), + right.timestamp.as_ref(), + )?, + snapshot_descriptor: merge_metadata_high_waters( + "snapshot descriptor", + left.snapshot_descriptor.as_ref(), + right.snapshot_descriptor.as_ref(), + )?, + snapshot: merge_metadata_high_waters( + "snapshot", + left.snapshot.as_ref(), + right.snapshot.as_ref(), + )?, + targets_descriptor: merge_metadata_high_waters( + "targets descriptor", + left.targets_descriptor.as_ref(), + right.targets_descriptor.as_ref(), + )?, + targets: merge_metadata_high_waters( + "targets", + left.targets.as_ref(), + right.targets.as_ref(), + )?, + })) + } + (Some(left), Some(right)) => { + let (prior, observed) = if left.root.version < right.root.version { + (left, right) + } else { + (right, left) + }; + let merged = merge_repository_observation(Some(prior), observed); + if let Some(error) = merged.error { + return Err(error); + } + Ok(Some(merged.high_water)) + } + } +} + +fn merge_metadata_high_waters( + role: &str, + left: Option<&MetadataHighWater>, + right: Option<&MetadataHighWater>, +) -> Result> { + match (left, right) { + (None, None) => Ok(None), + (Some(value), None) | (None, Some(value)) => Ok(Some(value.clone())), + (Some(left), Some(right)) if left.version == right.version => { + if left.sha256 != right.sha256 { + return Err(policy_error(format!( + "conflicting TUF {role} metadata hashes exist at high-water version {}", + left.version + ))); + } + Ok(Some(MetadataHighWater { + version: left.version, + sha256: left.sha256.clone(), + authority: merge_optional_authority_provenance( + role, + "asserting", + left.authority.as_ref(), + right.authority.as_ref(), + )?, + referenced_authority: merge_optional_authority_provenance( + role, + "referenced", + left.referenced_authority.as_ref(), + right.referenced_authority.as_ref(), + )?, + })) + } + (Some(left), Some(right)) => Ok(Some(if left.version > right.version { + left.clone() + } else { + right.clone() + })), + } +} + +fn merge_optional_authority_provenance( + role: &str, + kind: &str, + left: Option<&AuthorityProvenance>, + right: Option<&AuthorityProvenance>, +) -> Result> { + match (left, right) { + (None, None) => Ok(None), + (Some(left), Some(right)) => Ok(Some(merge_authority_provenance(left, right)?)), + _ => Err(policy_error(format!( + "conflicting TUF {role} {kind}-authority provenance exists at the same high-water version" + ))), + } +} + +struct RepositoryObservationMerge { + high_water: RepositoryHighWater, + error: Option, + accepted_through_targets: bool, +} + +fn merge_repository_observation( + prior: Option<&RepositoryHighWater>, + observed: &RepositoryHighWater, +) -> RepositoryObservationMerge { + let Some(prior) = prior else { + return RepositoryObservationMerge { + high_water: observed.clone(), + error: None, + accepted_through_targets: observed.timestamp.is_some() + && observed.snapshot_descriptor.is_some() + && observed.snapshot.is_some() + && observed.targets_descriptor.is_some() + && observed.targets.is_some(), + }; + }; + if let Err(error) = enforce_metadata_high_water("root", Some(&observed.root), Some(&prior.root)) + { + // Child roles authenticated under an obsolete or equivocated root are + // not safe observations. Retain the entire prior floor so an attacker + // with old online-role keys cannot fast-forward child versions and + // permanently block recovery. + return RepositoryObservationMerge { + high_water: prior.clone(), + error: Some(error), + accepted_through_targets: false, + }; + } + let resets = match authority_resets(prior, observed) { + Ok(resets) => resets, + Err(error) => { + return RepositoryObservationMerge { + high_water: prior.clone(), + error: Some(error), + accepted_through_targets: false, + } + } + }; + let authority_history = match advance_authority_history(prior, observed) { + Ok(history) => history, + Err(error) => { + return RepositoryObservationMerge { + high_water: prior.clone(), + error: Some(error), + accepted_through_targets: false, + } + } + }; + let baseline = (|| -> Result { + Ok(RepositoryHighWater { + root: observed.root.clone(), + root_authority: observed.root_authority.clone(), + timestamp_authority: observed.timestamp_authority.clone(), + snapshot_authority: observed.snapshot_authority.clone(), + targets_authority: observed.targets_authority.clone(), + authority_history: authority_history.clone(), + timestamp: if resets.timestamp { + None + } else { + taint_role_floor(prior.timestamp.as_ref(), &observed.timestamp_authority)? + }, + snapshot_descriptor: if resets.snapshot_descriptor { + None + } else { + taint_descriptor_floor( + prior.snapshot_descriptor.as_ref(), + &observed.timestamp_authority, + &observed.snapshot_authority, + )? + }, + snapshot: if resets.snapshot { + None + } else { + taint_role_floor(prior.snapshot.as_ref(), &observed.snapshot_authority)? + }, + targets_descriptor: if resets.targets_descriptor { + None + } else { + taint_descriptor_floor( + prior.targets_descriptor.as_ref(), + &observed.snapshot_authority, + &observed.targets_authority, + )? + }, + targets: if resets.targets { + None + } else { + taint_role_floor(prior.targets.as_ref(), &observed.targets_authority)? + }, + }) + })(); + let baseline = match baseline { + Ok(baseline) => baseline, + Err(error) => { + return RepositoryObservationMerge { + high_water: prior.clone(), + error: Some(error), + accepted_through_targets: false, + } + } + }; + let mut error = None; + let (timestamp, timestamp_accepted) = merge_metadata_observation_chain_link( + "timestamp", + baseline.timestamp.as_ref(), + observed.timestamp.as_ref(), + &mut error, + ); + let (snapshot_descriptor, snapshot_descriptor_accepted) = + merge_metadata_observation_chain_link_if_parent( + timestamp_accepted, + "snapshot descriptor", + baseline.snapshot_descriptor.as_ref(), + observed.snapshot_descriptor.as_ref(), + &mut error, + ); + let (snapshot, snapshot_accepted) = merge_metadata_observation_chain_link_if_parent( + snapshot_descriptor_accepted, + "snapshot", + baseline.snapshot.as_ref(), + observed.snapshot.as_ref(), + &mut error, + ); + let (targets_descriptor, targets_descriptor_accepted) = + merge_metadata_observation_chain_link_if_parent( + snapshot_accepted, + "targets descriptor", + baseline.targets_descriptor.as_ref(), + observed.targets_descriptor.as_ref(), + &mut error, + ); + let (targets, targets_accepted) = merge_metadata_observation_chain_link_if_parent( + targets_descriptor_accepted, + "targets", + baseline.targets.as_ref(), + observed.targets.as_ref(), + &mut error, + ); + RepositoryObservationMerge { + high_water: RepositoryHighWater { + root: observed.root.clone(), + root_authority: observed.root_authority.clone(), + timestamp_authority: observed.timestamp_authority.clone(), + snapshot_authority: observed.snapshot_authority.clone(), + targets_authority: observed.targets_authority.clone(), + authority_history, + timestamp, + snapshot_descriptor, + snapshot, + targets_descriptor, + targets, + }, + error, + accepted_through_targets: targets_accepted, + } +} + +fn taint_role_floor( + floor: Option<&MetadataHighWater>, + authority: &RoleAuthority, +) -> Result> { + floor + .cloned() + .map(|mut floor| { + floor.authority = floor + .authority + .as_ref() + .map(|prior| union_authority_provenance(prior, authority)) + .transpose()?; + Ok(floor) + }) + .transpose() +} + +fn taint_descriptor_floor( + floor: Option<&MetadataHighWater>, + authority: &RoleAuthority, + referenced_authority: &RoleAuthority, +) -> Result> { + floor + .cloned() + .map(|mut floor| { + floor.authority = floor + .authority + .as_ref() + .map(|prior| union_authority_provenance(prior, authority)) + .transpose()?; + floor.referenced_authority = floor + .referenced_authority + .as_ref() + .map(|prior| union_authority_provenance(prior, referenced_authority)) + .transpose()?; + Ok(floor) + }) + .transpose() +} + +fn merge_metadata_observation_chain_link_if_parent( + parent_accepted: bool, + role: &str, + prior: Option<&MetadataHighWater>, + observed: Option<&MetadataHighWater>, + error: &mut Option, +) -> (Option, bool) { + if !parent_accepted { + return (prior.cloned(), false); + } + merge_metadata_observation_chain_link(role, prior, observed, error) +} + +fn merge_metadata_observation_chain_link( + role: &str, + prior: Option<&MetadataHighWater>, + observed: Option<&MetadataHighWater>, + error: &mut Option, +) -> (Option, bool) { + match (prior, observed) { + (None, None) => (None, false), + (Some(prior), None) => (Some(prior.clone()), false), + (None, Some(observed)) => (Some(observed.clone()), true), + (Some(prior), Some(observed)) => { + match enforce_metadata_high_water(role, Some(observed), Some(prior)) { + Ok(()) if observed.version == prior.version => { + // The baseline was already widened to every authority + // that can authenticate the same detachable-signature + // payload in the new root epoch. Preserve that conservative + // union for an equal semantic payload. + (Some(prior.clone()), true) + } + Ok(()) => (Some(observed.clone()), true), + Err(merge_error) => { + error.get_or_insert(merge_error); + (Some(prior.clone()), false) + } + } + } + } +} + +fn enforce_metadata_high_water( + role: &str, + candidate: Option<&MetadataHighWater>, + prior: Option<&MetadataHighWater>, +) -> Result<()> { + match (candidate, prior) { + (_, None) => Ok(()), + (None, Some(prior)) => Err(policy_error(format!( + "TUF {role} metadata rollback: previously accepted version {} is missing", + prior.version + ))), + (Some(candidate), Some(prior)) if candidate.version < prior.version => { + Err(policy_error(format!( + "TUF {role} metadata rollback: previously accepted version {}, received {}", + prior.version, candidate.version + ))) + } + (Some(candidate), Some(prior)) + if candidate.version == prior.version && candidate.sha256 != prior.sha256 => + { + Err(policy_error(format!( + "TUF {role} metadata changed without incrementing version {}", + candidate.version + ))) + } + (Some(_), Some(_)) => Ok(()), + } +} + +fn validate_repository_high_water(high_water: &RepositoryHighWater) -> Result<()> { + validate_metadata_high_water("root", &high_water.root)?; + if high_water.root.authority.is_some() || high_water.root.referenced_authority.is_some() { + return Err(policy_error( + "TUF root high-water mark must not carry online-role authority provenance", + )); + } + validate_role_authority("root", &high_water.root_authority)?; + validate_role_authority("timestamp", &high_water.timestamp_authority)?; + validate_role_authority("snapshot", &high_water.snapshot_authority)?; + validate_role_authority("targets", &high_water.targets_authority)?; + for (role, current, history) in [ + ( + "root", + &high_water.root_authority, + high_water.authority_history.root.as_slice(), + ), + ( + "timestamp", + &high_water.timestamp_authority, + high_water.authority_history.timestamp.as_slice(), + ), + ( + "snapshot", + &high_water.snapshot_authority, + high_water.authority_history.snapshot.as_slice(), + ), + ( + "targets", + &high_water.targets_authority, + high_water.authority_history.targets.as_slice(), + ), + ] { + validate_authority_key_history(role, history)?; + if !current + .key_fingerprints + .iter() + .all(|key| history.binary_search(key).is_ok()) + { + return Err(policy_error(format!( + "TUF {role} authority history does not contain the current authority" + ))); + } + } + validate_authority_custody_classes(&high_water.authority_history)?; + for (role, mark, current) in [ + ( + "timestamp", + high_water.timestamp.as_ref(), + &high_water.timestamp_authority, + ), + ( + "snapshot", + high_water.snapshot.as_ref(), + &high_water.snapshot_authority, + ), + ( + "targets", + high_water.targets.as_ref(), + &high_water.targets_authority, + ), + ] { + let Some(mark) = mark else { + continue; + }; + validate_metadata_high_water(role, mark)?; + let authority = mark.authority.as_ref().ok_or_else(|| { + policy_error(format!( + "TUF {role} high-water mark is missing authority provenance" + )) + })?; + validate_authority_provenance(role, authority)?; + validate_provenance_covers_authority(role, authority, current)?; + if mark.referenced_authority.is_some() { + return Err(policy_error(format!( + "TUF {role} envelope high-water mark must not carry referenced-authority provenance" + ))); + } + } + for (role, mark, parent, child) in [ + ( + "snapshot descriptor", + high_water.snapshot_descriptor.as_ref(), + &high_water.timestamp_authority, + &high_water.snapshot_authority, + ), + ( + "targets descriptor", + high_water.targets_descriptor.as_ref(), + &high_water.snapshot_authority, + &high_water.targets_authority, + ), + ] { + let Some(mark) = mark else { + continue; + }; + validate_metadata_high_water(role, mark)?; + let authority = mark.authority.as_ref().ok_or_else(|| { + policy_error(format!( + "TUF {role} high-water mark is missing asserting-authority provenance" + )) + })?; + let referenced_authority = mark.referenced_authority.as_ref().ok_or_else(|| { + policy_error(format!( + "TUF {role} high-water mark is missing referenced-authority provenance" + )) + })?; + validate_authority_provenance(role, authority)?; + validate_authority_provenance(&format!("{role} referenced role"), referenced_authority)?; + validate_provenance_covers_authority(role, authority, parent)?; + validate_provenance_covers_authority( + &format!("{role} referenced role"), + referenced_authority, + child, + )?; + } + Ok(()) +} + +fn validate_provenance_covers_authority( + role: &str, + provenance: &AuthorityProvenance, + current: &RoleAuthority, +) -> Result<()> { + if current + .key_fingerprints + .iter() + .all(|key| provenance.key_fingerprints.binary_search(key).is_ok()) + { + Ok(()) + } else { + Err(policy_error(format!( + "TUF {role} floor provenance does not cover the current authority" + ))) + } +} + +fn validate_role_authority(role: &str, authority: &RoleAuthority) -> Result<()> { + if authority.threshold == 0 + || authority.threshold > authority.key_fingerprints.len() + || authority.key_fingerprints.len() > MAX_AUTHORITY_KEYS + { + return Err(policy_error(format!( + "TUF {role} authority threshold is invalid" + ))); + } + let mut prior = None; + for fingerprint in &authority.key_fingerprints { + validate_hex( + &format!("TUF {role} authority key fingerprint"), + fingerprint, + SHA256_HEX_LEN, + )?; + if prior.is_some_and(|prior: &String| prior >= fingerprint) { + return Err(policy_error(format!( + "TUF {role} authority key fingerprints must be unique and sorted" + ))); + } + prior = Some(fingerprint); + } + Ok(()) +} + +fn validate_authority_provenance(role: &str, authority: &AuthorityProvenance) -> Result<()> { + if authority.key_fingerprints.is_empty() + || authority.key_fingerprints.len() > MAX_AUTHORITY_KEYS + { + return Err(policy_error(format!( + "TUF {role} authority provenance must contain between 1 and {MAX_AUTHORITY_KEYS} keys" + ))); + } + let mut prior = None; + for fingerprint in &authority.key_fingerprints { + validate_hex( + &format!("TUF {role} authority key fingerprint"), + fingerprint, + SHA256_HEX_LEN, + )?; + if prior.is_some_and(|prior: &String| prior >= fingerprint) { + return Err(policy_error(format!( + "TUF {role} authority key fingerprints must be unique and sorted" + ))); + } + prior = Some(fingerprint); + } + Ok(()) +} + +fn validate_metadata_high_water(role: &str, mark: &MetadataHighWater) -> Result<()> { + if mark.version == 0 { + return Err(policy_error(format!( + "TUF {role} high-water version must be greater than zero" + ))); + } + validate_hex( + &format!("TUF {role} high-water SHA-256"), + &mark.sha256, + SHA256_HEX_LEN, + )?; + Ok(()) +} + +fn repository_id(repository_url: &str) -> String { + sha256_hex(repository_url.as_bytes()) +} + +fn repository_memory_state(config: &TrustedReleaseConfig) -> Arc { + let Some(cache_path) = &config.cache_path else { + // Explicitly non-persistent managers do not claim shared rollback + // protection and therefore do not retain a process-global registry + // entry indefinitely. + return Arc::new(RepositoryMemoryState::default()); + }; + let path = std::path::absolute(cache_path).unwrap_or_else(|_| cache_path.clone()); + let key = format!( + "{}:{}", + repository_id(&config.repository_url), + path.to_string_lossy() + ); + let registry = REPOSITORY_MEMORY_STATES.get_or_init(|| StdMutex::new(BTreeMap::new())); + let mut registry = registry + .lock() + .expect("repository memory-state registry mutex poisoned"); + Arc::clone( + registry + .entry(key) + .or_insert_with(|| Arc::new(RepositoryMemoryState::default())), + ) +} + +fn default_cache_path(repository_url: &str) -> Result { + let repository_id = repository_id(repository_url); + let file = format!("tuf-{}.json", &repository_id[..16]); + directories::ProjectDirs::from("ai", "Maple", "OpenSecret") + .map(|directories| { + directories + .data_local_dir() + .join("attestations") + .join(&file) + }) + .ok_or_else(|| { + policy_error( + "no durable application-data directory is available for attestation rollback state; mobile hosts must use TrustedReleaseManager::official_with_cache_path or TrustedReleaseConfig::new_with_cache_path", + ) + }) +} + +fn lock_cache(path: &Path) -> Result { + let parent = cache_parent(path)?; + std::fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| policy_error("cache path has no UTF-8 file name"))?; + let lock_path = parent.join(format!(".{file_name}.lock")); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(lock_path)?; + file.lock_exclusive()?; + Ok(file) +} + +fn read_cache(path: &Path, expected_repository_id: &str) -> Result { + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(CachedRepository::default()) + } + Err(error) => return Err(error.into()), + }; + if metadata.len() > MAX_CACHE_BYTES { + return Err(policy_error("attestation policy cache exceeds size limit")); + } + let raw = std::fs::read(path)?; + let cache: CacheFile = serde_json::from_slice(&raw)?; + if cache.schema != CACHE_SCHEMA || cache.repository_id != expected_repository_id { + return Err(policy_error("attestation policy cache identity mismatch")); + } + validate_hex( + "attestation policy cache repositoryId", + &cache.repository_id, + SHA256_HEX_LEN, + )?; + validate_repository_high_water(&cache.repository_high_water)?; + if cache.channel_high_water.len() > 2 { + return Err(policy_error( + "attestation policy cache may have at most two channel high-water marks", + )); + } + for high_water in cache.channel_high_water.values() { + validate_channel_high_water(high_water)?; + validate_provenance_covers_authority( + "channel targets", + &high_water.authority, + &cache.repository_high_water.targets_authority, + )?; + } + if cache.entries.len() > MAX_CACHE_ENTRIES { + return Err(policy_error( + "attestation policy cache has too many entries", + )); + } + let mut total = 0u64; + let mut decoded = BTreeMap::new(); + for (name, encoded) in cache.entries { + validate_store_name(&name) + .map_err(|error| policy_error(format!("invalid cache entry: {error}")))?; + let bytes = BASE64 + .decode(encoded) + .map_err(|error| policy_error(format!("invalid cache encoding: {error}")))?; + total = total.saturating_add(bytes.len() as u64); + if total > MAX_CACHE_BYTES { + return Err(policy_error( + "attestation policy cache exceeds decoded size limit", + )); + } + decoded.insert(name, bytes); + } + Ok(CachedRepository { + repository_high_water: Some(cache.repository_high_water), + channel_high_water: cache.channel_high_water, + entries: decoded, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn persist_cache_while_locked( + cache_guard: &mut Option, + path: PathBuf, + repository_id: String, + repository_high_water: RepositoryHighWater, + channel_high_water: BTreeMap, + entries: BTreeMap>, + task_name: &'static str, +) -> Result<()> { + run_blocking_with_cache_lock(cache_guard, task_name, move || { + persist_cache( + &path, + &repository_id, + &repository_high_water, + &channel_high_water, + &entries, + ) + }) + .await +} + +async fn run_blocking_with_cache_lock( + cache_guard: &mut Option, + task_name: &'static str, + operation: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + let held_guard = cache_guard.take().ok_or_else(|| { + policy_error(format!( + "{task_name} persistence attempted without the cache lock" + )) + })?; + let task = tokio::task::spawn_blocking(move || { + let result = operation(); + // Return the guard to the caller only after the atomic write finishes. + // If the async caller is cancelled, Tokio drops this output after the + // blocking task completes, so the lock still outlives the write. + (result, held_guard) + }) + .await + .map_err(|error| policy_error(format!("{task_name} task failed: {error}")))?; + let (result, held_guard) = task; + *cache_guard = Some(held_guard); + result.map_err(|error| policy_error(format!("{task_name} persistence failed: {error}"))) +} + +fn persist_cache( + path: &Path, + repository_id: &str, + repository_high_water: &RepositoryHighWater, + channel_high_water: &BTreeMap, + entries: &BTreeMap>, +) -> Result<()> { + validate_hex( + "attestation policy cache repositoryId", + repository_id, + SHA256_HEX_LEN, + )?; + validate_repository_high_water(repository_high_water)?; + if channel_high_water.len() > 2 { + return Err(policy_error( + "refusing to persist invalid channel high-water marks", + )); + } + for high_water in channel_high_water.values() { + validate_channel_high_water(high_water)?; + validate_provenance_covers_authority( + "channel targets", + &high_water.authority, + &repository_high_water.targets_authority, + )?; + } + if entries.len() > MAX_CACHE_ENTRIES { + return Err(policy_error("refusing to persist oversized policy cache")); + } + let encoded = entries + .iter() + .map(|(name, bytes)| (name.clone(), BASE64.encode(bytes))) + .collect(); + let cache = CacheFile { + schema: CACHE_SCHEMA.to_string(), + repository_id: repository_id.to_string(), + repository_high_water: repository_high_water.clone(), + channel_high_water: channel_high_water.clone(), + entries: encoded, + }; + let bytes = serde_json::to_vec(&cache)?; + if bytes.len() as u64 > MAX_CACHE_BYTES { + return Err(policy_error("refusing to persist oversized policy cache")); + } + let parent = cache_parent(path)?; + std::fs::create_dir_all(parent)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(&bytes)?; + temporary.as_file().sync_all()?; + let persisted = temporary + .persist(path) + .map_err(|error| Error::Io(error.error))?; + persisted.sync_all()?; + sync_parent_directory(parent)?; + Ok(()) +} + +fn validate_channel_high_water(high_water: &CacheHighWater) -> Result<()> { + if high_water.sequence == 0 { + return Err(policy_error( + "attestation policy cache has an invalid channel sequence", + )); + } + validate_hex( + "attestation policy cache policyId", + &high_water.policy_id, + SHA256_HEX_LEN, + )?; + validate_authority_provenance("channel targets", &high_water.authority) +} + +fn cache_parent(path: &Path) -> Result<&Path> { + let parent = path + .parent() + .ok_or_else(|| policy_error("cache path has no parent directory"))?; + // `Path::parent` returns an empty path for a bare relative filename. Use + // the current directory explicitly so temporary-file creation and the + // durability fsync target the same real directory as the final rename. + Ok(if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }) +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> Result<()> { + File::open(parent)?.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> Result<()> { + Ok(()) +} + +fn policy_error(message: impl Into) -> Error { + Error::TrustedReleasePolicy(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + use sigstore_verify::crypto::KeyPair; + use std::{ + collections::{BTreeSet, HashMap}, + sync::atomic::{AtomicUsize, Ordering}, + }; + use tokio::sync::Barrier; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + fn document(pcr0: u8, pcr1: u8, pcr2: u8) -> AttestationDocument { + AttestationDocument { + module_id: "test".to_string(), + digest: "SHA384".to_string(), + timestamp: 0, + pcrs: HashMap::from([ + (0, vec![pcr0; 48]), + (1, vec![pcr1; 48]), + (2, vec![pcr2; 48]), + ]), + certificate: Vec::new(), + cabundle: Vec::new(), + public_key: None, + user_data: None, + nonce: None, + } + } + + #[derive(Clone, Default)] + struct MemoryRepository { + metadata: HashMap>, + targets: HashMap>, + } + + impl Repository for MemoryRepository { + fn fetch_metadata<'a>(&'a self, name: &'a str, max_length: u64) -> FetchFuture<'a> { + let result = match self.metadata.get(name) { + Some(bytes) if bytes.len() as u64 > max_length => Err( + sigstore_tuf::Error::Transport("metadata too large".to_string()), + ), + bytes => Ok(bytes.cloned()), + }; + Box::pin(async move { result }) + } + + fn fetch_target<'a>(&'a self, path: &'a str, max_length: u64) -> FetchFuture<'a> { + let result = match self.targets.get(path) { + Some(bytes) if bytes.len() as u64 > max_length => Err( + sigstore_tuf::Error::Transport("target too large".to_string()), + ), + bytes => Ok(bytes.cloned()), + }; + Box::pin(async move { result }) + } + } + + struct FixtureBundleVerifier { + fail: bool, + } + + impl BundleVerifier for FixtureBundleVerifier { + fn verify( + &self, + manifest_bytes: &[u8], + bundle_bytes: &[u8], + trusted_root_bytes: &[u8], + ) -> Result<()> { + if self.fail { + return Err(policy_error("fixture bundle rejected")); + } + assert!(std::str::from_utf8(manifest_bytes) + .unwrap() + .contains("opensecret-backend")); + assert_eq!(bundle_bytes, b"fixture-bundle"); + assert_eq!(trusted_root_bytes, b"fixture-trusted-root"); + Ok(()) + } + } + + fn tuf_key_entry(key_pair: &KeyPair) -> (String, Value) { + let public = key_pair.public_key_der().unwrap().to_pem(); + let value = json!({ + "keytype": "ecdsa", + "scheme": "ecdsa-sha2-nistp256", + "keyval": { "public": public }, + }); + let key: sigstore_tuf::Key = serde_json::from_value(value.clone()).unwrap(); + (key.key_id().unwrap(), value) + } + + fn tuf_key_fingerprint(key: &Value) -> String { + let parsed: sigstore_tuf::Key = serde_json::from_value(key.clone()).unwrap(); + let verification = parsed.verification_key().unwrap(); + key_custody_fingerprint(&parsed.scheme, verification.as_bytes()).unwrap() + } + + fn tuf_signature(signed: &Value, key_id: &str, key_pair: &KeyPair) -> Value { + let canonical = sigstore_tuf::canonical_json::to_canonical_bytes(signed).unwrap(); + let signature = key_pair.sign(&canonical).unwrap(); + json!({ "keyid": key_id, "sig": hex::encode(signature.as_bytes()) }) + } + + fn tuf_envelope(signed: Value, key_id: &str, key_pair: &KeyPair) -> Vec { + serde_json::to_vec(&json!({ + "signatures": [tuf_signature(&signed, key_id, key_pair)], + "signed": signed, + })) + .unwrap() + } + + #[allow(clippy::too_many_arguments)] + fn tuf_root( + version: u64, + root_key_id: &str, + root_key: &Value, + root_key_pair: &KeyPair, + online_key_id: &str, + online_key: &Value, + ) -> Vec { + tuf_root_with_expiry( + version, + root_key_id, + root_key, + root_key_pair, + online_key_id, + online_key, + "2027-01-01T00:00:00Z", + ) + } + + #[allow(clippy::too_many_arguments)] + fn tuf_root_with_expiry( + version: u64, + root_key_id: &str, + root_key: &Value, + root_key_pair: &KeyPair, + online_key_id: &str, + online_key: &Value, + expires: &str, + ) -> Vec { + let signed = json!({ + "_type": "root", + "spec_version": "1.0.0", + "version": version, + "expires": expires, + "consistent_snapshot": true, + "keys": { root_key_id: root_key, online_key_id: online_key }, + "roles": { + "root": { "keyids": [root_key_id], "threshold": 1 }, + "timestamp": { "keyids": [online_key_id], "threshold": 1 }, + "snapshot": { "keyids": [online_key_id], "threshold": 1 }, + "targets": { "keyids": [online_key_id], "threshold": 1 }, + }, + }); + tuf_envelope(signed, root_key_id, root_key_pair) + } + + #[allow(clippy::too_many_arguments)] + fn tuf_root_with_role_bindings( + version: u64, + root_key_id: &str, + root_key: &Value, + root_key_pair: &KeyPair, + online_keys: &[(&str, &Value)], + timestamp: (&[&str], usize), + snapshot: (&[&str], usize), + targets: (&[&str], usize), + ) -> Vec { + let mut keys = serde_json::Map::new(); + keys.insert(root_key_id.to_string(), root_key.clone()); + for (key_id, key) in online_keys { + keys.insert((*key_id).to_string(), (*key).clone()); + } + let signed = json!({ + "_type": "root", + "spec_version": "1.0.0", + "version": version, + "expires": "2027-01-01T00:00:00Z", + "consistent_snapshot": true, + "keys": Value::Object(keys), + "roles": { + "root": { "keyids": [root_key_id], "threshold": 1 }, + "timestamp": { "keyids": timestamp.0, "threshold": timestamp.1 }, + "snapshot": { "keyids": snapshot.0, "threshold": snapshot.1 }, + "targets": { "keyids": targets.0, "threshold": targets.1 }, + }, + }); + tuf_envelope(signed, root_key_id, root_key_pair) + } + + #[allow(clippy::too_many_arguments)] + fn tuf_root_with_custom_root_role( + version: u64, + keys: &[(&str, &Value)], + root: (&[&str], usize), + timestamp: (&[&str], usize), + snapshot: (&[&str], usize), + targets: (&[&str], usize), + signers: &[(&str, &KeyPair)], + ) -> Vec { + let mut key_map = serde_json::Map::new(); + for (key_id, key) in keys { + key_map.insert((*key_id).to_string(), (*key).clone()); + } + let signed = json!({ + "_type": "root", + "spec_version": "1.0.0", + "version": version, + "expires": "2027-01-01T00:00:00Z", + "consistent_snapshot": true, + "keys": Value::Object(key_map), + "roles": { + "root": { "keyids": root.0, "threshold": root.1 }, + "timestamp": { "keyids": timestamp.0, "threshold": timestamp.1 }, + "snapshot": { "keyids": snapshot.0, "threshold": snapshot.1 }, + "targets": { "keyids": targets.0, "threshold": targets.1 }, + }, + }); + let signatures = signers + .iter() + .map(|(key_id, key_pair)| tuf_signature(&signed, key_id, key_pair)) + .collect::>(); + serde_json::to_vec(&json!({ + "signatures": signatures, + "signed": signed, + })) + .unwrap() + } + + fn repository_floor_from_root(root_bytes: &[u8], version: u64) -> RepositoryHighWater { + let trusted = sigstore_tuf::TrustedMetadataSet::from_root(root_bytes).unwrap(); + let authorities = root_role_authorities(trusted.root()).unwrap(); + let mark = |byte: char, authority: &RoleAuthority| MetadataHighWater { + version, + sha256: byte.to_string().repeat(SHA256_HEX_LEN), + authority: Some(authority.into()), + referenced_authority: None, + }; + let descriptor = + |byte: char, authority: &RoleAuthority, referenced_authority: &RoleAuthority| { + MetadataHighWater { + version, + sha256: byte.to_string().repeat(SHA256_HEX_LEN), + authority: Some(authority.into()), + referenced_authority: Some(referenced_authority.into()), + } + }; + RepositoryHighWater { + root: MetadataHighWater { + version: trusted.root().version, + sha256: signed_metadata_sha256("root", trusted.root_bytes()).unwrap(), + authority: None, + referenced_authority: None, + }, + root_authority: authorities.root.clone(), + timestamp_authority: authorities.timestamp.clone(), + snapshot_authority: authorities.snapshot.clone(), + targets_authority: authorities.targets.clone(), + authority_history: AuthorityHistory::from_authorities( + &authorities.root, + &authorities.timestamp, + &authorities.snapshot, + &authorities.targets, + ), + timestamp: Some(mark('1', &authorities.timestamp)), + snapshot_descriptor: Some(descriptor( + '2', + &authorities.timestamp, + &authorities.snapshot, + )), + snapshot: Some(mark('3', &authorities.snapshot)), + targets_descriptor: Some(descriptor('4', &authorities.snapshot, &authorities.targets)), + targets: Some(mark('5', &authorities.targets)), + } + } + + fn metadata_pin(bytes: &[u8], version: u64) -> Value { + json!({ + "version": version, + "length": bytes.len(), + "hashes": { "sha256": sha256_hex(bytes) }, + }) + } + + fn target_pin(bytes: &[u8]) -> Value { + json!({ + "length": bytes.len(), + "hashes": { "sha256": sha256_hex(bytes) }, + }) + } + + fn consistent_target_path(path: &str, digest: &str) -> String { + match path.rsplit_once('/') { + Some((directory, file)) => format!("{directory}/{digest}.{file}"), + None => format!("{digest}.{path}"), + } + } + + fn build_policy_repository( + timestamp_expires: &str, + bad_sigstore_root_digest: bool, + active_releases: bool, + ) -> (MemoryRepository, Vec) { + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + build_policy_repository_generation( + timestamp_expires, + bad_sigstore_root_digest, + active_releases, + &key_pair, + 1, + if active_releases { 7 } else { 8 }, + true, + ) + } + + fn build_policy_repository_generation( + timestamp_expires: &str, + bad_sigstore_root_digest: bool, + active_releases: bool, + key_pair: &KeyPair, + metadata_version: u64, + channel_sequence: u64, + publish_channel_target: bool, + ) -> (MemoryRepository, Vec) { + build_policy_repository_generation_with_channel_padding( + timestamp_expires, + bad_sigstore_root_digest, + active_releases, + key_pair, + metadata_version, + channel_sequence, + publish_channel_target, + 0, + ) + } + + #[allow(clippy::too_many_arguments)] + fn build_policy_repository_generation_with_channel_padding( + timestamp_expires: &str, + bad_sigstore_root_digest: bool, + active_releases: bool, + key_pair: &KeyPair, + metadata_version: u64, + channel_sequence: u64, + publish_channel_target: bool, + channel_padding_bytes: usize, + ) -> (MemoryRepository, Vec) { + let (key_id, key) = tuf_key_entry(key_pair); + let root_key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_key_id, root_key) = tuf_key_entry(&root_key_pair); + let manifest_path = "releases/1.2.3/prod/manifest.json"; + let bundle_path = "releases/1.2.3/prod/manifest.sigstore.json"; + let trusted_root_path = "sigstore/trusted_root.json"; + + let manifest = serde_json::to_vec(&json!({ + "schema": MANIFEST_SCHEMA, + "component": COMPONENT, + "environment": "prod", + "release": { "version": "1.2.3" }, + "source": { + "uri": "https://source.example/OpenSecretCloud/opensecret", + "path": "nix/enclave", + "ref": "refs/tags/v1.2.3", + "revision": { "algorithm": "git-sha1", "digest": "a".repeat(40) }, + }, + "artifact": { + "name": "opensecret-1.2.3-prod.eif", + "mediaType": EIF_MEDIA_TYPE, + "size": 42, + "digests": { "sha256": "b".repeat(64) }, + }, + "measurements": { + "algorithm": "sha384", + "requiredPcrs": [0, 1, 2], + "pcrs": { + "0": "01".repeat(48), + "1": "02".repeat(48), + "2": "03".repeat(48), + }, + }, + "build": { + "system": "nix", + "builderId": "portable-nix-builder", + "derivation": "eif-prod", + "flakeLockSha256": "c".repeat(64), + "runUri": "https://ci.example/runs/1", + }, + })) + .unwrap(); + let bundle = b"fixture-bundle".to_vec(); + let trusted_root = b"fixture-trusted-root".to_vec(); + let trusted_root_digest = if bad_sigstore_root_digest { + "0".repeat(64) + } else { + sha256_hex(&trusted_root) + }; + let active = if active_releases { + json!([{ + "manifestTarget": manifest_path, + "manifestSha256": sha256_hex(&manifest), + "bundleTarget": bundle_path, + "bundleSha256": sha256_hex(&bundle), + }]) + } else { + json!([]) + }; + let mut channel_value = json!({ + "schema": CHANNEL_SCHEMA, + "environment": "prod", + "sequence": channel_sequence, + "sigstoreTrustedRootTarget": { "path": trusted_root_path, "sha256": trusted_root_digest }, + "active": active, + }); + if channel_padding_bytes > 0 { + channel_value["padding"] = Value::String("x".repeat(channel_padding_bytes)); + } + let channel = serde_json::to_vec(&channel_value).unwrap(); + + let logical_targets = BTreeMap::from([ + ("channels/prod.json".to_string(), channel), + (trusted_root_path.to_string(), trusted_root), + (manifest_path.to_string(), manifest), + (bundle_path.to_string(), bundle), + ]); + let target_entries = logical_targets + .iter() + .map(|(path, bytes)| (path.clone(), target_pin(bytes))) + .collect::>(); + let targets_signed = json!({ + "_type": "targets", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": "2026-09-01T00:00:00Z", + "targets": target_entries, + }); + let targets = tuf_envelope(targets_signed, &key_id, key_pair); + let snapshot_signed = json!({ + "_type": "snapshot", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": "2026-09-01T00:00:00Z", + "meta": { "targets.json": metadata_pin(&targets, metadata_version) }, + }); + let snapshot = tuf_envelope(snapshot_signed, &key_id, key_pair); + let timestamp_signed = json!({ + "_type": "timestamp", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": timestamp_expires, + "meta": { "snapshot.json": metadata_pin(&snapshot, metadata_version) }, + }); + let timestamp = tuf_envelope(timestamp_signed, &key_id, key_pair); + let root = tuf_root(1, &root_key_id, &root_key, &root_key_pair, &key_id, &key); + + let mut repository = MemoryRepository::default(); + repository + .metadata + .insert("timestamp.json".to_string(), timestamp); + repository + .metadata + .insert(format!("{metadata_version}.snapshot.json"), snapshot); + repository + .metadata + .insert(format!("{metadata_version}.targets.json"), targets); + for (path, bytes) in logical_targets { + if path == "channels/prod.json" && !publish_channel_target { + continue; + } + let digest = sha256_hex(&bytes); + repository + .targets + .insert(consistent_target_path(&path, &digest), bytes); + } + (repository, root) + } + + #[test] + fn pcr_tuple_cannot_be_mixed_between_active_releases() { + let policy = TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + vec![ + ("1.0.0", [1; 48], [2; 48], [3; 48]), + ("1.1.0", [4; 48], [5; 48], [6; 48]), + ], + ); + assert!(policy.verify_attestation(&document(1, 2, 3)).is_ok()); + assert!(policy.verify_attestation(&document(4, 5, 6)).is_ok()); + assert!(matches!( + policy.verify_attestation(&document(1, 5, 6)), + Err(Error::AttestationVerificationFailed(_)) + )); + } + + #[test] + fn pcr_authorization_rechecks_policy_expiry_at_the_exact_boundary() { + let mut policy = TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + vec![("1.0.0", [1; 48], [2; 48], [3; 48])], + ); + policy.valid_until = "2026-08-30T00:00:00Z".parse().unwrap(); + assert!(policy + .verify_attestation_at( + &document(1, 2, 3), + "2026-08-29T23:59:59.999999999Z".parse().unwrap(), + ) + .is_ok()); + assert!(matches!( + policy.verify_attestation_at( + &document(1, 2, 3), + "2026-08-30T00:00:00Z".parse().unwrap(), + ), + Err(Error::TrustedReleasePolicy(_)) + )); + } + + #[tokio::test] + async fn concurrent_refresh_waiters_share_one_in_flight_result_only() { + let coordinator = Arc::new(Mutex::new(RefreshCoordinator::default())); + let calls = Arc::new(AtomicUsize::new(0)); + let barrier = Arc::new(Barrier::new(17)); + let mut tasks = Vec::new(); + for _ in 0..16 { + let coordinator = Arc::clone(&coordinator); + let calls = Arc::clone(&calls); + let barrier = Arc::clone(&barrier); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + coalesce_refresh(coordinator, move || async move { + calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(50)).await; + Ok(TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 7, + vec![("1.2.3", [1; 48], [2; 48], [3; 48])], + )) + }) + .await + .unwrap() + .sequence() + })); + } + barrier.wait().await; + for task in tasks { + assert_eq!(task.await.unwrap(), 7); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let later_calls = Arc::clone(&calls); + let later = coalesce_refresh(Arc::clone(&coordinator), move || async move { + later_calls.fetch_add(1, Ordering::SeqCst); + Ok(TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 8, + Vec::new(), + )) + }) + .await + .unwrap(); + assert_eq!(later.sequence(), 8); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn caller_cancellation_does_not_cancel_the_owned_refresh_worker() { + let coordinator = Arc::new(Mutex::new(RefreshCoordinator::default())); + let release = Arc::new(tokio::sync::Notify::new()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let held = TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 6, + vec![("1.2.2", [1; 48], [2; 48], [3; 48])], + ); + let manager = TrustedReleaseManager::fixed_for_test(held.clone()); + let leader_coordinator = Arc::clone(&coordinator); + let worker_release = Arc::clone(&release); + let worker_manager = Arc::clone(&manager); + let leader = tokio::spawn(async move { + coalesce_refresh(leader_coordinator, move || async move { + let _ = started_tx.send(()); + worker_release.notified().await; + let policy = TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 7, + vec![("1.2.3", [1; 48], [2; 48], [3; 48])], + ); + worker_manager.install_policy_floor_for_test(&policy); + Ok(policy) + }) + .await + }); + started_rx.await.unwrap(); + leader.abort(); + assert!(leader.await.unwrap_err().is_cancelled()); + + let fallback_calls = Arc::new(AtomicUsize::new(0)); + let waiter_calls = Arc::clone(&fallback_calls); + let waiter = tokio::spawn(coalesce_refresh( + Arc::clone(&coordinator), + move || async move { + waiter_calls.fetch_add(1, Ordering::SeqCst); + Ok(TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 99, + Vec::new(), + )) + }, + )); + release.notify_one(); + assert_eq!(waiter.await.unwrap().unwrap().sequence(), 7); + assert_eq!(fallback_calls.load(Ordering::SeqCst), 0); + assert!(matches!( + manager.assert_policy_current(&held).await, + Err(Error::TrustedReleasePolicy(_)) + )); + } + + #[tokio::test] + async fn held_policy_is_rejected_after_a_concurrent_channel_floor_advances() { + let held = TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 7, + vec![("1.2.3", [1; 48], [2; 48], [3; 48])], + ); + let manager = TrustedReleaseManager::fixed_for_test(held.clone()); + assert!(manager.assert_policy_current(&held).await.is_ok()); + + let revoked = + TrustedReleasePolicy::for_test(AttestationEnvironment::Production, 8, Vec::new()); + manager.install_policy_floor_for_test(&revoked); + assert!(matches!( + manager.assert_policy_current(&held).await, + Err(Error::TrustedReleasePolicy(_)) + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancelled_persistence_keeps_the_cross_process_lock_until_write_finishes() { + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("cache.json"); + let initial_guard = lock_cache(&cache_path).unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + + let writer = tokio::spawn(async move { + let mut guard = Some(initial_guard); + run_blocking_with_cache_lock(&mut guard, "cancellation test", move || { + let _ = started_tx.send(()); + release_rx.recv().expect("test releases the blocked writer"); + Ok(()) + }) + .await + }); + started_rx.await.unwrap(); + writer.abort(); + assert!(writer.await.unwrap_err().is_cancelled()); + + let contender_path = cache_path.clone(); + let (acquired_tx, mut acquired_rx) = tokio::sync::oneshot::channel(); + let contender = tokio::task::spawn_blocking(move || { + let guard = lock_cache(&contender_path); + let _ = acquired_tx.send(()); + guard + }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut acquired_rx) + .await + .is_err() + ); + + release_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(2), &mut acquired_rx) + .await + .expect("the contender acquires after the detached write finishes") + .unwrap(); + let contender_guard = contender.await.unwrap().unwrap(); + drop(contender_guard); + } + + #[tokio::test] + async fn signed_tuf_repository_resolves_and_reverifies_offline() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (repository, root) = build_policy_repository("2026-08-30T00:00:00Z", false, true); + let store = Arc::new(SnapshotStore::default()); + let policy = resolve_policy_with_final_time( + repository, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + assert_eq!(policy.sequence(), 7); + assert!(policy + .verify_attestation_at(&document(1, 2, 3), now) + .is_ok()); + + let offline = resolve_policy_with_final_time( + StoreRepository::new(Arc::clone(&store)), + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("the complete cache must reverify without network access"); + assert_eq!(offline.policy_id(), policy.policy_id()); + } + + #[tokio::test] + async fn verified_generation_pruning_discards_obsolete_release_entries() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (repository, root) = build_policy_repository("2026-08-30T00:00:00Z", false, true); + let obsolete = (0..MAX_CACHE_ENTRIES) + .map(|index| { + ( + format!("targets/releases/0.0.{index}/prod/obsolete.sigstore.json"), + vec![index as u8; 32], + ) + }) + .collect(); + let store = Arc::new(SnapshotStore::from_entries(obsolete)); + let policy = resolve_policy_with_final_time( + repository, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + + let retained = store.entries(); + assert!(retained.len() < 16, "cache must stay generation-bounded"); + assert!(!retained.keys().any(|name| name.contains("obsolete"))); + assert!(retained.contains_key("timestamp.json")); + assert!(retained.contains_key("targets/channels/prod.json")); + assert!(retained.contains_key("targets/releases/1.2.3/prod/manifest.json")); + + let directory = tempfile::tempdir().unwrap(); + let high_water = BTreeMap::from([( + AttestationEnvironment::Production, + CacheHighWater::from_policy(&policy), + )]); + persist_cache( + &directory.path().join("cache.json"), + &repository_id(REPOSITORY_URL), + &policy.repository_high_water, + &high_water, + &retained, + ) + .expect("a pruned rollover generation must remain persistable"); + } + + #[tokio::test] + async fn metadata_is_rechecked_at_the_end_of_a_slow_refresh() { + let initial_now: jiff::Timestamp = "2026-08-29T23:59:59Z".parse().unwrap(); + let expires = "2026-08-30T00:00:00Z"; + let (repository, root) = build_policy_repository(expires, false, true); + let before_expiry: jiff::Timestamp = "2026-08-29T23:59:59.999999999Z".parse().unwrap(); + resolve_policy_with_final_time( + repository.clone(), + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + initial_now, + &FixtureBundleVerifier { fail: false }, + || before_expiry, + ) + .await + .expect("metadata remains usable immediately before its expiry instant"); + + let error = resolve_policy_with_final_time( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + initial_now, + &FixtureBundleVerifier { fail: false }, + || expires.parse().unwrap(), + ) + .await + .unwrap_err(); + assert!(matches!(error, RefreshFailure::Security(_))); + } + + #[tokio::test] + async fn authenticated_revoke_all_persists_as_an_offline_deny_policy() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (mut repository, root) = build_policy_repository("2026-08-30T00:00:00Z", false, false); + // Once the authenticated channel revokes everything, policy/root and + // release targets are no longer required. Their unavailability must + // not trigger fallback to an older active generation. + repository + .targets + .retain(|path, _| path.starts_with("channels/")); + let store = Arc::new(SnapshotStore::default()); + let policy = resolve_policy_with_final_time( + repository, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("an authenticated empty channel is a valid deny-all policy"); + assert_eq!(policy.sequence(), 8); + assert!(matches!( + policy.verify_attestation_at(&document(1, 2, 3), now), + Err(Error::UnreleasedAttestationPolicy { .. }) + )); + + let offline = resolve_policy_with_final_time( + StoreRepository::new(Arc::clone(&store)), + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("the cached revoke-all policy must reverify without network access"); + assert_eq!(offline.policy_id(), policy.policy_id()); + assert!(matches!( + offline.verify_attestation_at(&document(1, 2, 3), now), + Err(Error::UnreleasedAttestationPolicy { .. }) + )); + } + + #[tokio::test] + async fn newer_metadata_with_missing_changed_channel_cannot_fallback() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (generation_a, root) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 1, + 7, + true, + ); + let store = Arc::new(SnapshotStore::default()); + let policy_a = resolve_policy_with_final_time( + generation_a.clone(), + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + + let (generation_b, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 2, + 8, + false, + ); + let error = resolve_policy_with_final_time( + generation_b, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + RefreshFailure::UnavailableAfterChannel(Error::TrustedReleaseNetwork(_)) + )); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("journal.json"); + let config = TrustedReleaseConfig::new( + AttestationEnvironment::Production, + "https://attestations.invalid/tuf/", + root.clone(), + ) + .unwrap() + .with_cache_path(&cache_path); + let manager = TrustedReleaseManager::new(config).unwrap(); + let mut cache_guard = manager.acquire_cache_lock().await.unwrap(); + let prior_channel = CacheHighWater::from_policy(&policy_a); + let journal = manager + .record_authenticated_observation( + Arc::clone(&store), + now, + Some(&policy_a.repository_high_water), + BTreeMap::from([(AttestationEnvironment::Production, prior_channel.clone())]), + &mut cache_guard, + ) + .await + .expect("the authenticated v2 metadata must be journaled") + .cache; + assert_eq!( + journal + .repository_high_water + .as_ref() + .and_then(|high_water| high_water.targets.as_ref()) + .map(|high_water| high_water.version), + Some(2) + ); + assert_eq!( + journal + .channel_high_water + .get(&AttestationEnvironment::Production) + .map(|high_water| high_water.sequence), + Some(7), + "a missing changed channel must not erase the prior sequence floor" + ); + + drop(manager); + let reloaded = read_cache( + &cache_path, + &repository_id("https://attestations.invalid/tuf/"), + ) + .unwrap(); + let replay_store = Arc::new(SnapshotStore::from_entries(reloaded.entries.clone())); + let replay = resolve_policy_with_final_time( + generation_a, + Arc::clone(&replay_store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await; + if let Ok(policy) = replay { + enforce_repository_high_water( + &policy.repository_high_water, + reloaded.repository_high_water.as_ref(), + ) + .expect_err("a restarted client must reject replay of metadata v1"); + } + + let (generation_b_repaired, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 2, + 8, + true, + ); + let repaired_store = Arc::new(SnapshotStore::from_entries(reloaded.entries)); + let repaired = resolve_policy_with_final_time( + generation_b_repaired, + repaired_store, + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("the repaired v2 generation must remain recoverable"); + enforce_repository_high_water( + &repaired.repository_high_water, + reloaded.repository_high_water.as_ref(), + ) + .unwrap(); + enforce_high_water( + &repaired, + reloaded + .channel_high_water + .get(&AttestationEnvironment::Production), + ) + .unwrap(); + } + + #[tokio::test] + async fn oversized_authenticated_channel_cannot_erase_new_metadata_floors() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (generation_v1, root) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 1, + 7, + true, + ); + let store = Arc::new(SnapshotStore::default()); + let policy_v1 = resolve_policy_with_final_time( + generation_v1.clone(), + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + + let (oversized_v2, _) = build_policy_repository_generation_with_channel_padding( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 2, + 8, + true, + MAX_CHANNEL_BYTES, + ); + assert!(matches!( + resolve_policy_with_final_time( + oversized_v2, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await, + Err(RefreshFailure::Security(_)) + )); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("journal.json"); + let repository_url = "https://oversized.attestations.invalid/tuf/"; + let manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new( + AttestationEnvironment::Production, + repository_url, + root.clone(), + ) + .unwrap() + .with_cache_path(&cache_path), + ) + .unwrap(); + let mut cache_guard = manager.acquire_cache_lock().await.unwrap(); + let prior_channel = CacheHighWater::from_policy(&policy_v1); + let journal = manager + .record_authenticated_observation( + store, + now, + Some(&policy_v1.repository_high_water), + BTreeMap::from([(AttestationEnvironment::Production, prior_channel.clone())]), + &mut cache_guard, + ) + .await + .expect("oversized channel must not block repository journaling") + .cache; + assert_eq!( + journal + .repository_high_water + .as_ref() + .and_then(|high_water| high_water.targets.as_ref()) + .map(|high_water| high_water.version), + Some(2) + ); + assert_eq!( + journal + .channel_high_water + .get(&AttestationEnvironment::Production), + Some(&prior_channel) + ); + + let reloaded = read_cache(&cache_path, &repository_id(repository_url)).unwrap(); + let replay = resolve_policy_with_final_time( + generation_v1, + Arc::new(SnapshotStore::from_entries(reloaded.entries)), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await; + assert!( + replay.is_err(), + "metadata v1 must not replay after observed v2" + ); + } + + #[tokio::test] + async fn first_run_partial_metadata_journal_survives_restart_without_a_channel() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (generation_v2, root) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 2, + 8, + false, + ); + let store = Arc::new(SnapshotStore::default()); + assert!(matches!( + resolve_policy_with_final_time( + generation_v2, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await, + Err(RefreshFailure::UnavailableAfterChannel(_)) + )); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("journal.json"); + let repository_url = "https://attestations.invalid/tuf/"; + let manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new( + AttestationEnvironment::Production, + repository_url, + root.clone(), + ) + .unwrap() + .with_cache_path(&cache_path), + ) + .unwrap(); + let mut cache_guard = manager.acquire_cache_lock().await.unwrap(); + let journal = manager + .record_authenticated_observation(store, now, None, BTreeMap::new(), &mut cache_guard) + .await + .unwrap() + .cache; + assert!(journal.channel_high_water.is_empty()); + assert_eq!( + journal + .repository_high_water + .as_ref() + .and_then(|high_water| high_water.targets.as_ref()) + .map(|high_water| high_water.version), + Some(2) + ); + + drop(manager); + let reloaded = read_cache(&cache_path, &repository_id(repository_url)).unwrap(); + assert!(reloaded.channel_high_water.is_empty()); + let (generation_v1, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 1, + 7, + true, + ); + let replay_store = Arc::new(SnapshotStore::from_entries(reloaded.entries)); + let replay = resolve_policy_with_final_time( + generation_v1, + replay_store, + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await; + assert!( + replay.is_err(), + "metadata v1 must not replay after v2 was journaled" + ); + } + + #[tokio::test] + async fn rejected_channel_rollback_still_journals_new_repository_metadata() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (generation_v1, root) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 1, + 10, + true, + ); + let store = Arc::new(SnapshotStore::default()); + let policy_v1 = resolve_policy_with_final_time( + generation_v1.clone(), + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + + let (generation_v2, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 2, + 9, + true, + ); + let policy_v2 = resolve_policy_with_final_time( + generation_v2, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + let prior_channel = CacheHighWater::from_policy(&policy_v1); + assert!(enforce_high_water(&policy_v2, Some(&prior_channel)).is_err()); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("journal.json"); + let repository_url = "https://attestations.invalid/tuf/"; + let manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new( + AttestationEnvironment::Production, + repository_url, + root.clone(), + ) + .unwrap() + .with_cache_path(&cache_path), + ) + .unwrap(); + let mut cache_guard = manager.acquire_cache_lock().await.unwrap(); + let recorded = manager + .record_authenticated_observation( + store, + now, + Some(&policy_v1.repository_high_water), + BTreeMap::from([(AttestationEnvironment::Production, prior_channel.clone())]), + &mut cache_guard, + ) + .await + .unwrap(); + assert!(recorded.observation_error.is_some()); + assert_eq!( + recorded + .cache + .repository_high_water + .as_ref() + .and_then(|high_water| high_water.targets.as_ref()) + .map(|high_water| high_water.version), + Some(2) + ); + assert_eq!( + recorded + .cache + .channel_high_water + .get(&AttestationEnvironment::Production) + .map(|high_water| high_water.sequence), + Some(10) + ); + + let reloaded = read_cache(&cache_path, &repository_id(repository_url)).unwrap(); + let replay = resolve_policy_with_final_time( + generation_v1, + Arc::new(SnapshotStore::from_entries(reloaded.entries)), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await; + assert!( + replay.is_err(), + "repository metadata v1 must not replay after v2" + ); + } + + #[tokio::test] + async fn journal_write_failure_retains_authenticated_in_process_floors() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (repository, root) = build_policy_repository("2026-08-30T00:00:00Z", false, true); + let store = Arc::new(SnapshotStore::default()); + let policy = resolve_policy_with_final_time( + repository, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let blocker = directory.path().join("not-a-directory"); + std::fs::write(&blocker, b"block directory creation").unwrap(); + let cache_path = blocker.join("journal.json"); + let repository_url = "https://attestations.invalid/tuf/"; + let manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new( + AttestationEnvironment::Production, + repository_url, + root.clone(), + ) + .unwrap() + .with_cache_path(&cache_path), + ) + .unwrap(); + let development_manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new(AttestationEnvironment::Development, repository_url, root) + .unwrap() + .with_cache_path(&cache_path), + ) + .unwrap(); + assert!(Arc::ptr_eq( + &manager.memory_state, + &development_manager.memory_state + )); + let mut cache_guard = None; + let error = manager + .record_authenticated_observation(store, now, None, BTreeMap::new(), &mut cache_guard) + .await + .unwrap_err(); + assert!(matches!( + error, + Error::Io(_) | Error::TrustedReleasePolicy(_) + )); + assert_eq!( + development_manager + .memory_state + .state + .lock() + .unwrap() + .high_water + .channels + .get(&AttestationEnvironment::Production) + .map(|high_water| high_water.sequence), + Some(policy.sequence()) + ); + assert_eq!( + development_manager + .memory_state + .state + .lock() + .unwrap() + .high_water + .repository + .as_ref() + .and_then(|high_water| high_water.targets.as_ref()) + .map(|high_water| high_water.version), + Some(1) + ); + } + + #[tokio::test] + async fn expired_verified_root_rotation_is_journaled_and_can_recover_forward() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (key_id, key) = tuf_key_entry(&key_pair); + let root_key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_key_id, root_key) = tuf_key_entry(&root_key_pair); + let root_v1 = tuf_root(1, &root_key_id, &root_key, &root_key_pair, &key_id, &key); + let root_v2 = tuf_root_with_expiry( + 2, + &root_key_id, + &root_key, + &root_key_pair, + &key_id, + &key, + "2026-08-28T00:00:00Z", + ); + let mut expired_rotation = MemoryRepository::default(); + expired_rotation + .metadata + .insert("2.root.json".to_string(), root_v2); + let store = Arc::new(SnapshotStore::default()); + assert!(matches!( + resolve_policy_with_final_time( + expired_rotation, + Arc::clone(&store), + &root_v1, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await, + Err(RefreshFailure::Security(_)) + )); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("journal.json"); + let repository_url = "https://attestations.invalid/tuf/"; + let manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new( + AttestationEnvironment::Production, + repository_url, + root_v1.clone(), + ) + .unwrap() + .with_cache_path(&cache_path), + ) + .unwrap(); + let mut cache_guard = manager.acquire_cache_lock().await.unwrap(); + let journal = manager + .record_authenticated_observation(store, now, None, BTreeMap::new(), &mut cache_guard) + .await + .unwrap() + .cache; + assert_eq!( + journal.repository_high_water.as_ref().unwrap().root.version, + 2 + ); + + let reloaded = read_cache(&cache_path, &repository_id(repository_url)).unwrap(); + let (mut repaired, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &key_pair, + 1, + 7, + true, + ); + repaired.metadata.insert( + "3.root.json".to_string(), + tuf_root(3, &root_key_id, &root_key, &root_key_pair, &key_id, &key), + ); + let repaired_policy = resolve_policy_with_final_time( + repaired, + Arc::new(SnapshotStore::from_entries(reloaded.entries)), + &root_v1, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("a valid root v3 must recover forward from expired root v2"); + assert_eq!(repaired_policy.repository_high_water.root.version, 3); + enforce_repository_high_water( + &repaired_policy.repository_high_water, + reloaded.repository_high_water.as_ref(), + ) + .unwrap(); + } + + #[tokio::test] + async fn authenticated_channel_digest_mismatch_is_security_failure() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (repository, root) = build_policy_repository("2026-08-30T00:00:00Z", true, true); + let error = resolve_policy_with_final_time( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap_err(); + assert!(matches!(error, RefreshFailure::Security(_))); + } + + #[tokio::test] + async fn expired_tuf_timestamp_is_security_failure() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (repository, root) = build_policy_repository("2026-08-28T00:00:00Z", false, true); + let error = resolve_policy_with_final_time( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .unwrap_err(); + assert!(matches!(error, RefreshFailure::Security(_))); + } + + #[tokio::test] + async fn rejected_sigstore_bundle_is_security_failure() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let (repository, root) = build_policy_repository("2026-08-30T00:00:00Z", false, true); + let error = resolve_policy_with_final_time( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: true }, + || now, + ) + .await + .unwrap_err(); + assert!(matches!(error, RefreshFailure::Security(_))); + } + + #[test] + fn official_placeholder_fails_closed() { + assert!(is_unpublished_root(EMBEDDED_TUF_ROOT)); + validate_official_embedded_root(EMBEDDED_TUF_ROOT).unwrap(); + } + + #[test] + fn official_bootstrap_requires_signed_root_version_one_but_custom_roots_remain_flexible() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_id, online_key) = tuf_key_entry(&online_pair); + let root_v1 = tuf_root(1, &root_id, &root_key, &root_pair, &online_id, &online_key); + let root_v2 = tuf_root(2, &root_id, &root_key, &root_pair, &online_id, &online_key); + + validate_official_embedded_root(&root_v1).unwrap(); + let error = validate_official_embedded_root(&root_v2).unwrap_err(); + assert!(error + .to_string() + .contains("official embedded TUF root signed version must be exactly 1; found 2")); + + let directory = tempfile::tempdir().unwrap(); + let custom = TrustedReleaseConfig::new_with_cache_path( + AttestationEnvironment::Production, + "https://attestations.example/tuf/", + root_v2, + directory.path().join("custom-v2.json"), + ) + .unwrap(); + TrustedReleaseManager::new(custom).unwrap(); + } + + #[test] + fn mobile_host_can_supply_durable_state_path_for_the_official_root() { + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("attestation-state.json"); + let manager = TrustedReleaseManager::official_with_cache_path( + AttestationEnvironment::Production, + &cache_path, + ) + .unwrap(); + assert_eq!(manager.config.repository_url, REPOSITORY_URL); + assert_eq!( + manager.config.cache_path.as_deref(), + Some(cache_path.as_path()) + ); + assert!(is_unpublished_root(&manager.config.tuf_root)); + } + + #[test] + fn official_trust_domain_requires_exact_repository_root_environment_and_persistence() { + let directory = tempfile::tempdir().unwrap(); + let valid = TrustedReleaseManager::official_with_cache_path( + AttestationEnvironment::Production, + directory.path().join("valid.json"), + ) + .unwrap(); + valid + .validate_official_trust_domain(AttestationEnvironment::Production) + .unwrap(); + + let custom_repository = TrustedReleaseManager::new( + TrustedReleaseConfig::new_with_cache_path( + AttestationEnvironment::Production, + "https://attestations.example/tuf/", + EMBEDDED_TUF_ROOT.to_vec(), + directory.path().join("repository.json"), + ) + .unwrap(), + ) + .unwrap(); + assert!(custom_repository + .validate_official_trust_domain(AttestationEnvironment::Production) + .unwrap_err() + .to_string() + .contains("canonical attestation repository")); + + let custom_root = TrustedReleaseManager::new( + TrustedReleaseConfig::new_with_cache_path( + AttestationEnvironment::Production, + REPOSITORY_URL, + b"custom root".to_vec(), + directory.path().join("root.json"), + ) + .unwrap(), + ) + .unwrap(); + assert!(custom_root + .validate_official_trust_domain(AttestationEnvironment::Production) + .unwrap_err() + .to_string() + .contains("embedded TUF bootstrap root")); + + let ephemeral = TrustedReleaseManager::new( + TrustedReleaseConfig::new( + AttestationEnvironment::Production, + REPOSITORY_URL, + EMBEDDED_TUF_ROOT.to_vec(), + ) + .unwrap() + .without_persistent_cache(), + ) + .unwrap(); + assert!(ephemeral + .validate_official_trust_domain(AttestationEnvironment::Production) + .unwrap_err() + .to_string() + .contains("persistent attestation rollback state")); + + assert!(valid + .validate_official_trust_domain(AttestationEnvironment::Development) + .unwrap_err() + .to_string() + .contains("expected 'dev'")); + } + + #[test] + fn equivalent_repository_urls_share_one_canonical_cache_identity() { + let without_slash = TrustedReleaseConfig::new( + AttestationEnvironment::Production, + "https://EXAMPLE.com/tuf", + b"{}".to_vec(), + ) + .unwrap(); + let with_slash = TrustedReleaseConfig::new( + AttestationEnvironment::Development, + "https://example.com/tuf/", + b"{}".to_vec(), + ) + .unwrap(); + assert_eq!(without_slash.repository_url, "https://example.com/tuf/"); + assert_eq!(without_slash.repository_url, with_slash.repository_url); + assert_eq!(without_slash.cache_path, with_slash.cache_path); + assert_eq!( + repository_id(&without_slash.repository_url), + repository_id(&with_slash.repository_url) + ); + } + + #[test] + fn release_targets_are_environment_isolated() { + let active = ActiveRelease { + manifest_target: "releases/1.2.3/dev/manifest.json".to_string(), + manifest_sha256: "a".repeat(64), + bundle_target: "releases/1.2.3/dev/manifest.sigstore.json".to_string(), + bundle_sha256: "b".repeat(64), + }; + assert!(validate_release_targets(&active, AttestationEnvironment::Development).is_ok()); + assert!(validate_release_targets(&active, AttestationEnvironment::Production).is_err()); + } + + #[test] + fn manifest_audit_fields_and_urls_match_the_wire_profile() { + assert!(validate_identifier("builder ID", "builder_1.test").is_ok()); + assert!(validate_identifier("builder ID", "_builder").is_err()); + assert!(validate_identifier("builder ID", &format!("a{}", "b".repeat(256))).is_err()); + assert!(validate_https_url("source.uri", "https://source.example/project").is_ok()); + assert!(validate_https_url("build.runUri", "https://ci.example/runs/1?secret=x").is_err()); + assert!(validate_source_path(".").is_ok()); + assert!(validate_source_path("nix/enclave").is_ok()); + assert!(validate_source_path("nix/./enclave").is_err()); + assert!(validate_source_path("../enclave").is_err()); + } + + #[test] + fn only_transport_errors_allow_cache_fallback() { + assert!(matches!( + classify_tuf_error( + "refresh", + sigstore_tuf::Error::Transport(format!("{TUF_UNAVAILABLE_PREFIX}offline")) + ), + RefreshFailure::Unavailable(Error::TrustedReleaseNetwork(_)) + )); + assert!(matches!( + classify_tuf_error( + "refresh", + sigstore_tuf::Error::Transport("timestamp.json not found".to_string()) + ), + RefreshFailure::Unavailable(Error::TrustedReleaseNetwork(_)) + )); + assert!(matches!( + prevent_fallback_after_channel(RefreshFailure::Unavailable( + Error::TrustedReleaseNetwork("missing channel".to_string()) + )), + RefreshFailure::UnavailableAfterChannel(Error::TrustedReleaseNetwork(_)) + )); + for error in [ + sigstore_tuf::Error::Expired { + role: "timestamp".to_string(), + expires: "2026-01-01T00:00:00Z".to_string(), + }, + sigstore_tuf::Error::Rollback { + role: "targets".to_string(), + trusted: 2, + new: 1, + }, + sigstore_tuf::Error::IntegrityMismatch("tampered".to_string()), + sigstore_tuf::Error::Transport("response exceeds size limit".to_string()), + sigstore_tuf::Error::Transport("GET returned status 302".to_string()), + ] { + assert!(matches!( + classify_tuf_error("refresh", error), + RefreshFailure::Security(_) + )); + } + } + + #[test] + fn signed_timestamp_is_limited_to_48_hours() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + assert!(validate_timestamp_window("2026-08-31T00:00:00Z", now).is_ok()); + assert!(validate_timestamp_window("2026-08-31T00:00:01Z", now).is_err()); + assert!(validate_timestamp_window("2026-08-28T23:59:59Z", now).is_err()); + } + + #[tokio::test] + async fn tuf_root_rotation_limit_allows_root_33_but_rejects_root_34() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_id, online_key) = tuf_key_entry(&online_pair); + let make_root = |version| { + tuf_root( + version, + &root_id, + &root_key, + &root_pair, + &online_id, + &online_key, + ) + }; + let root_v1 = make_root(1); + let mut through_33 = HashMap::new(); + for version in 2..=33 { + through_33.insert(format!("{version}.root.json"), make_root(version)); + } + + let now = "2026-08-29T00:00:00Z".parse().unwrap(); + let mut updater = Updater::new( + MemoryRepository { + metadata: through_33.clone(), + targets: HashMap::new(), + }, + &root_v1, + ) + .unwrap() + .with_config(tuf_updater_config()); + let error = updater.refresh(now).await.unwrap_err(); + assert_eq!(updater.trusted().root().version, 33); + assert!(!error.to_string().contains("maximum root rotations")); + + let root_v34 = make_root(34); + through_33.insert("34.root.json".to_string(), root_v34.clone()); + let through_34 = through_33.clone(); + let mut updater = Updater::new( + MemoryRepository { + metadata: through_33, + targets: HashMap::new(), + }, + &root_v1, + ) + .unwrap() + .with_config(tuf_updater_config()); + let error = updater.refresh(now).await.unwrap_err(); + assert!(error.to_string().contains("exceeded 33 root rotations")); + // sigstore-tuf has already adopted root 34 before returning its + // rotation-limit error. The SDK wrapper must never journal that root. + assert_eq!(updater.trusted().root().version, 34); + + let mut entries = + BTreeMap::from([("root_history/1.root.json".to_string(), root_v1.clone())]); + for version in 2..=34 { + entries.insert( + format!("root_history/{version}.root.json"), + through_34 + .get(&format!("{version}.root.json")) + .unwrap() + .clone(), + ); + } + let chain = + authenticated_root_authority_history(&root_v1, &entries, 34, &root_v34).unwrap(); + assert_eq!(chain.repository.root.version, 33); + assert!(chain + .error + .unwrap() + .to_string() + .contains("exceeds the supported 32 transitions")); + + let manager_entries = entries.clone(); + let store = Arc::new(SnapshotStore::from_entries(entries)); + let observation = capture_authenticated_observation(Arc::clone(&store), &root_v1, now) + .await + .unwrap(); + assert_eq!(observation.repository_high_water.root.version, 33); + assert!(observation + .error + .as_ref() + .unwrap() + .to_string() + .contains("exceeds the supported 32 transitions")); + assert!(!observation + .entries + .contains_key("root_history/34.root.json")); + assert_eq!( + root_transition_high_water(observation.entries.get("root.json").unwrap()) + .unwrap() + .root + .version, + 33 + ); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("root-limit.json"); + let repository_id = repository_id(REPOSITORY_URL); + let manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new_with_cache_path( + AttestationEnvironment::Production, + REPOSITORY_URL, + root_v1.clone(), + &cache_path, + ) + .unwrap(), + ) + .unwrap(); + let manager_store = Arc::new(SnapshotStore::from_entries(manager_entries)); + let mut cache_guard = manager.acquire_cache_lock().await.unwrap(); + let recorded = manager + .record_authenticated_observation( + manager_store, + now, + None, + BTreeMap::new(), + &mut cache_guard, + ) + .await + .unwrap(); + assert!(recorded + .observation_error + .unwrap() + .to_string() + .contains("exceeds the supported 32 transitions")); + let restarted = read_cache(&cache_path, &repository_id).unwrap(); + validate_cached_root_span(&root_v1, &restarted).unwrap(); + assert_eq!( + restarted + .repository_high_water + .as_ref() + .unwrap() + .root + .version, + 33 + ); + assert!(!restarted.entries.contains_key("root_history/34.root.json")); + + let poisoned = CachedRepository { + repository_high_water: Some(root_transition_high_water(&root_v34).unwrap()), + channel_high_water: BTreeMap::new(), + entries: BTreeMap::new(), + }; + assert!(validate_cached_root_span(&root_v1, &poisoned).is_err()); + + let poisoned_path = directory.path().join("root-limit-poisoned.json"); + let mut poisoned_entries = BTreeMap::from([ + ("root.json".to_string(), root_v34.clone()), + ("root_history/1.root.json".to_string(), root_v1.clone()), + ]); + for version in 2..=34 { + poisoned_entries.insert( + format!("root_history/{version}.root.json"), + through_34 + .get(&format!("{version}.root.json")) + .unwrap() + .clone(), + ); + } + persist_cache( + &poisoned_path, + &repository_id, + poisoned.repository_high_water.as_ref().unwrap(), + &poisoned.channel_high_water, + &poisoned_entries, + ) + .unwrap(); + let poisoned_manager = TrustedReleaseManager::new( + TrustedReleaseConfig::new_with_cache_path( + AttestationEnvironment::Production, + REPOSITORY_URL, + root_v1, + poisoned_path, + ) + .unwrap(), + ) + .unwrap(); + let error = poisoned_manager.load_cache().await.unwrap_err(); + assert!(error + .to_string() + .contains("exceeds the supported 32 transitions")); + + let custom_bootstrap = make_root(7); + let mut custom_entries = BTreeMap::from([( + "root_history/7.root.json".to_string(), + custom_bootstrap.clone(), + )]); + for version in 8..=40 { + custom_entries.insert( + format!("root_history/{version}.root.json"), + make_root(version), + ); + } + let custom_final = custom_entries + .get("root_history/40.root.json") + .unwrap() + .clone(); + let custom_chain = authenticated_root_authority_history( + &custom_bootstrap, + &custom_entries, + 40, + &custom_final, + ) + .unwrap(); + assert_eq!(custom_chain.repository.root.version, 39); + assert!(custom_chain + .error + .unwrap() + .to_string() + .contains("from bootstrap version 7")); + } + + #[tokio::test] + async fn root_ceiling_requires_exact_404_before_cached_policy_fallback() { + // This path exercises the production clock during cached fallback, so + // keep its timestamp fixture valid relative to the test run instead of + // tying the assertion to the date on which the test was authored. + let now = jiff::Timestamp::now(); + let timestamp_expires = (now + jiff::SignedDuration::from_hours(24)).to_string(); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (mut repository, _) = build_policy_repository_generation( + ×tamp_expires, + false, + true, + &online_pair, + 1, + 7, + true, + ); + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let (online_id, online_key) = tuf_key_entry(&online_pair); + let make_root = |version| { + tuf_root( + version, + &root_id, + &root_key, + &root_pair, + &online_id, + &online_key, + ) + }; + let root_v1 = make_root(1); + for version in 2..=33 { + repository + .metadata + .insert(format!("{version}.root.json"), make_root(version)); + } + let store = Arc::new(SnapshotStore::default()); + let cached_policy = resolve_policy_with_final_time( + repository, + Arc::clone(&store), + &root_v1, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("root 33 policy fixture must resolve"); + assert_eq!(cached_policy.repository_high_water.root.version, 33); + let cached_entries = store.entries(); + let cached_channels = BTreeMap::from([( + AttestationEnvironment::Production, + CacheHighWater::from_policy(&cached_policy), + )]); + let directory = tempfile::tempdir().unwrap(); + + let make_manager = |repository_url: &str, cache_path: &Path, timeout: Duration| { + let config = TrustedReleaseConfig { + environment: AttestationEnvironment::Production, + repository_url: repository_url.to_string(), + tuf_root: Arc::from(root_v1.clone()), + cache_path: Some(cache_path.to_path_buf()), + }; + TrustedReleaseManager { + repository: HttpTufRepository::new_with_timeout(repository_url, true, timeout) + .unwrap(), + config, + refresh_coordinator: Arc::new(Mutex::new(RefreshCoordinator::default())), + memory_state: Arc::new(RepositoryMemoryState::default()), + fixed_policy: None, + } + }; + + for status in [403, 408, 429, 500] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/34.root.json")) + .respond_with(ResponseTemplate::new(status)) + .mount(&server) + .await; + let repository_url = format!("{}/tuf/", server.uri()); + let cache_path = directory + .path() + .join(format!("root34-status-{status}.json")); + persist_cache( + &cache_path, + &repository_id(&repository_url), + &cached_policy.repository_high_water, + &cached_channels, + &cached_entries, + ) + .unwrap(); + let manager = make_manager(&repository_url, &cache_path, TUF_REQUEST_TIMEOUT); + let error = manager + .refresh_policy_inner_with_verifier(&FixtureBundleVerifier { fail: false }) + .await + .expect_err("a non-404 root 34 response must not authorize cached root 33"); + if status == 403 { + assert!(matches!(error, Error::TrustedReleasePolicy(_))); + } else { + assert!(matches!(error, Error::TrustedReleaseNetwork(_))); + } + + if status == 500 { + let restarted = make_manager(&repository_url, &cache_path, TUF_REQUEST_TIMEOUT); + let error = restarted + .refresh_policy_inner_with_verifier(&FixtureBundleVerifier { fail: false }) + .await + .expect_err( + "restart must not turn an unavailable root 34 probe into cached fallback", + ); + assert!(matches!(error, Error::TrustedReleaseNetwork(_))); + let persisted = read_cache(&cache_path, &repository_id(&repository_url)).unwrap(); + assert_eq!( + persisted + .repository_high_water + .as_ref() + .unwrap() + .root + .version, + 33 + ); + assert!(!persisted.entries.contains_key("root_history/34.root.json")); + } + } + + let timeout_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/34.root.json")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(250)) + .set_body_bytes(make_root(34)), + ) + .mount(&timeout_server) + .await; + let timeout_url = format!("{}/tuf/", timeout_server.uri()); + let timeout_cache = directory.path().join("root34-timeout.json"); + persist_cache( + &timeout_cache, + &repository_id(&timeout_url), + &cached_policy.repository_high_water, + &cached_channels, + &cached_entries, + ) + .unwrap(); + let manager = make_manager(&timeout_url, &timeout_cache, Duration::from_millis(25)); + assert!(matches!( + manager + .refresh_policy_inner_with_verifier(&FixtureBundleVerifier { fail: false }) + .await, + Err(Error::TrustedReleaseNetwork(_)) + )); + + let missing_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/34.root.json")) + .respond_with(ResponseTemplate::new(404)) + .mount(&missing_server) + .await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/timestamp.json")) + .respond_with(ResponseTemplate::new(503)) + .mount(&missing_server) + .await; + let missing_url = format!("{}/tuf/", missing_server.uri()); + let missing_cache = directory.path().join("root34-missing.json"); + persist_cache( + &missing_cache, + &repository_id(&missing_url), + &cached_policy.repository_high_water, + &cached_channels, + &cached_entries, + ) + .unwrap(); + let manager = make_manager(&missing_url, &missing_cache, TUF_REQUEST_TIMEOUT); + let fallback = manager + .refresh_policy_inner_with_verifier(&FixtureBundleVerifier { fail: false }) + .await + .expect("exact root 34 404 permits a still-valid cached policy after timestamp 503"); + assert_eq!(fallback.policy_id(), cached_policy.policy_id()); + assert_eq!(fallback.repository_high_water.root.version, 33); + } + + #[test] + fn persistent_cache_is_atomic_repository_bound_and_keeps_both_channels() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.json"); + let repository_id = repository_id(REPOSITORY_URL); + let channel_high_water = BTreeMap::from([ + ( + AttestationEnvironment::Production, + CacheHighWater::for_test(7, 'a', 'd'), + ), + ( + AttestationEnvironment::Development, + CacheHighWater::for_test(9, 'b', 'd'), + ), + ]); + let entries = BTreeMap::from([ + ("timestamp.json".to_string(), b"timestamp".to_vec()), + ( + "targets/channels/prod.json".to_string(), + b"channel".to_vec(), + ), + ]); + let repository_high_water = RepositoryHighWater::for_test(); + persist_cache( + &path, + &repository_id, + &repository_high_water, + &channel_high_water, + &entries, + ) + .unwrap(); + let cached = read_cache(&path, &repository_id).unwrap(); + assert_eq!(cached.entries, entries); + assert_eq!( + cached.repository_high_water.as_ref(), + Some(&repository_high_water) + ); + assert_eq!(cached.channel_high_water, channel_high_water); + assert!(read_cache(&path, &"c".repeat(SHA256_HEX_LEN)).is_err()); + } + + #[test] + fn unshipped_legacy_cache_schema_is_rejected_without_migration() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("legacy-cache.json"); + let repository_id = repository_id(REPOSITORY_URL); + persist_cache( + &path, + &repository_id, + &RepositoryHighWater::for_test(), + &BTreeMap::new(), + &BTreeMap::new(), + ) + .unwrap(); + let mut cache: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + cache["schema"] = + Value::String("https://attestations.trymaple.ai/schemas/sdk-tuf-cache/v3".to_string()); + std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap(); + + let error = read_cache(&path, &repository_id).unwrap_err(); + assert!(error + .to_string() + .contains("attestation policy cache identity mismatch")); + } + + #[test] + fn repository_global_cache_carries_root_history_between_channels() { + let production = TrustedReleaseConfig::new( + AttestationEnvironment::Production, + REPOSITORY_URL, + EMBEDDED_TUF_ROOT.to_vec(), + ) + .unwrap(); + let development = TrustedReleaseConfig::new( + AttestationEnvironment::Development, + REPOSITORY_URL, + EMBEDDED_TUF_ROOT.to_vec(), + ) + .unwrap(); + assert_eq!(production.cache_path, development.cache_path); + + let root_key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_key_id, root_key) = tuf_key_entry(&root_key_pair); + let online_key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_key_id, online_key) = tuf_key_entry(&online_key_pair); + let root_v1 = tuf_root( + 1, + &root_key_id, + &root_key, + &root_key_pair, + &online_key_id, + &online_key, + ); + let root_v2 = tuf_root( + 2, + &root_key_id, + &root_key, + &root_key_pair, + &online_key_id, + &online_key, + ); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("shared-cache.json"); + let repository_id = repository_id(REPOSITORY_URL); + let channel_high_water = BTreeMap::from([( + AttestationEnvironment::Production, + CacheHighWater::for_test(7, 'a', 'd'), + )]); + let entries = BTreeMap::from([ + ("root.json".to_string(), root_v2.clone()), + ("root_history/1.root.json".to_string(), root_v1.clone()), + ("root_history/2.root.json".to_string(), root_v2), + ]); + persist_cache( + &path, + &repository_id, + &RepositoryHighWater::for_test(), + &channel_high_water, + &entries, + ) + .unwrap(); + + // A development-channel manager reads the same repository cache even + // though only production has a channel sequence floor so far. + let cached_for_development = read_cache(&path, &repository_id).unwrap(); + assert!(!cached_for_development + .channel_high_water + .contains_key(&AttestationEnvironment::Development)); + let store = Arc::new(SnapshotStore::from_entries(cached_for_development.entries)); + let updater = Updater::new(MemoryRepository::default(), &root_v1) + .unwrap() + .with_store(store); + assert_eq!(updater.trusted().root().version, 2); + } + + #[test] + fn channel_high_water_rejects_rollback_and_equal_sequence_mutation() { + let prior = CacheHighWater::for_test(9, 'a', 'd'); + let rollback = TrustedReleasePolicy { + environment: AttestationEnvironment::Production, + sequence: 8, + policy_id: prior.policy_id.clone(), + repository_high_water: RepositoryHighWater::for_test(), + valid_until: jiff::Timestamp::MAX, + releases: Vec::new(), + }; + let equivocation = TrustedReleasePolicy { + environment: AttestationEnvironment::Production, + sequence: 9, + policy_id: "b".repeat(SHA256_HEX_LEN), + repository_high_water: RepositoryHighWater::for_test(), + valid_until: jiff::Timestamp::MAX, + releases: Vec::new(), + }; + let advance = TrustedReleasePolicy { + environment: AttestationEnvironment::Production, + sequence: 10, + policy_id: "c".repeat(SHA256_HEX_LEN), + repository_high_water: RepositoryHighWater::for_test(), + valid_until: jiff::Timestamp::MAX, + releases: Vec::new(), + }; + + assert!(enforce_high_water(&rollback, Some(&prior)).is_err()); + assert!(enforce_high_water(&equivocation, Some(&prior)).is_err()); + assert!(enforce_high_water(&advance, Some(&prior)).is_ok()); + } + + #[test] + fn channel_high_water_merge_keeps_the_strictest_floor() { + let older = CacheHighWater::for_test(7, 'a', 'd'); + let newer = CacheHighWater::for_test(8, 'b', 'd'); + assert_eq!( + merge_high_water(Some(&older), Some(&newer)).unwrap(), + Some(newer.clone()) + ); + + let conflicting = CacheHighWater::for_test(newer.sequence, 'c', 'd'); + assert!(merge_high_water(Some(&newer), Some(&conflicting)).is_err()); + } + + #[test] + fn repository_metadata_high_water_rejects_rollback_and_equivocation() { + let prior = RepositoryHighWater::for_test(); + let mut rollback = prior.clone(); + rollback.timestamp.as_mut().unwrap().version = 0; + assert!(enforce_repository_high_water(&rollback, Some(&prior)).is_err()); + + let mut equivocation = prior.clone(); + equivocation.targets.as_mut().unwrap().sha256 = "b".repeat(SHA256_HEX_LEN); + assert!(enforce_repository_high_water(&equivocation, Some(&prior)).is_err()); + + let mut advance = prior.clone(); + advance.timestamp.as_mut().unwrap().version += 1; + advance.timestamp.as_mut().unwrap().sha256 = "c".repeat(SHA256_HEX_LEN); + assert!(enforce_repository_high_water(&advance, Some(&prior)).is_ok()); + } + + #[test] + fn repository_high_water_requires_complete_disjoint_custody_history() { + let mut missing_root = RepositoryHighWater::for_test(); + missing_root.authority_history.root = RoleAuthority::for_test('f').key_fingerprints; + let error = validate_repository_high_water(&missing_root).unwrap_err(); + assert!(error + .to_string() + .contains("root authority history does not contain the current authority")); + + let mut crossed = RepositoryHighWater::for_test(); + crossed + .authority_history + .root + .extend(crossed.authority_history.timestamp.iter().cloned()); + crossed.authority_history.root.sort(); + crossed.authority_history.root.dedup(); + let error = validate_repository_high_water(&crossed).unwrap_err(); + assert!(error.to_string().contains("key custody class violation")); + } + + fn root_history_entries(roots: &[&[u8]]) -> BTreeMap> { + roots + .iter() + .map(|bytes| { + let root = root_transition_high_water(bytes).unwrap(); + ( + format!("root_history/{}.root.json", root.root.version), + bytes.to_vec(), + ) + }) + .collect() + } + + fn verify_root_chain(roots: &[&[u8]]) { + let (first, rest) = roots.split_first().unwrap(); + let mut trusted = sigstore_tuf::TrustedMetadataSet::from_root(first).unwrap(); + for root in rest { + trusted.update_root(root).unwrap(); + } + } + + #[test] + fn authority_recovery_resets_only_direct_claims_and_dependent_descriptors() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let timestamp_a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (timestamp_a_id, timestamp_a_key) = tuf_key_entry(×tamp_a_pair); + let timestamp_b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (timestamp_b_id, timestamp_b_key) = tuf_key_entry(×tamp_b_pair); + let snapshot_a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (snapshot_a_id, snapshot_a_key) = tuf_key_entry(&snapshot_a_pair); + let snapshot_b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (snapshot_b_id, snapshot_b_key) = tuf_key_entry(&snapshot_b_pair); + let targets_a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (targets_a_id, targets_a_key) = tuf_key_entry(&targets_a_pair); + let targets_b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (targets_b_id, targets_b_key) = tuf_key_entry(&targets_b_pair); + + let root_v1 = tuf_root_with_role_bindings( + 1, + &root_id, + &root_key, + &root_pair, + &[ + (×tamp_a_id, ×tamp_a_key), + (&snapshot_a_id, &snapshot_a_key), + (&targets_a_id, &targets_a_key), + ], + (&[timestamp_a_id.as_str()], 1), + (&[snapshot_a_id.as_str()], 1), + (&[targets_a_id.as_str()], 1), + ); + let prior = repository_floor_from_root(&root_v1, u64::MAX); + let channels = BTreeMap::from([ + ( + AttestationEnvironment::Production, + CacheHighWater { + sequence: 7, + policy_id: "a".repeat(SHA256_HEX_LEN), + authority: (&prior.targets_authority).into(), + }, + ), + ( + AttestationEnvironment::Development, + CacheHighWater { + sequence: 9, + policy_id: "b".repeat(SHA256_HEX_LEN), + authority: (&prior.targets_authority).into(), + }, + ), + ]); + + let timestamp_root = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[ + (×tamp_b_id, ×tamp_b_key), + (&snapshot_a_id, &snapshot_a_key), + (&targets_a_id, &targets_a_key), + ], + (&[timestamp_b_id.as_str()], 1), + (&[snapshot_a_id.as_str()], 1), + (&[targets_a_id.as_str()], 1), + ); + verify_root_chain(&[&root_v1, ×tamp_root]); + let observed = root_transition_high_water(×tamp_root).unwrap(); + let advanced = advance_security_floors_through_root_history( + Some(&prior), + channels.clone(), + &observed, + &root_history_entries(&[&root_v1, ×tamp_root]), + ) + .unwrap(); + let timestamp_state = advanced.repository.unwrap(); + assert!(timestamp_state.timestamp.is_none()); + assert!(timestamp_state.snapshot_descriptor.is_none()); + assert!(timestamp_state.snapshot.is_some()); + assert!(timestamp_state.targets_descriptor.is_some()); + assert!(timestamp_state.targets.is_some()); + assert_eq!(advanced.channels, channels); + + let snapshot_root = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[ + (×tamp_a_id, ×tamp_a_key), + (&snapshot_b_id, &snapshot_b_key), + (&targets_a_id, &targets_a_key), + ], + (&[timestamp_a_id.as_str()], 1), + (&[snapshot_b_id.as_str()], 1), + (&[targets_a_id.as_str()], 1), + ); + verify_root_chain(&[&root_v1, &snapshot_root]); + let observed = root_transition_high_water(&snapshot_root).unwrap(); + let advanced = advance_security_floors_through_root_history( + Some(&prior), + channels.clone(), + &observed, + &root_history_entries(&[&root_v1, &snapshot_root]), + ) + .unwrap(); + let snapshot_state = advanced.repository.unwrap(); + assert!(snapshot_state.timestamp.is_some()); + assert!(snapshot_state.snapshot_descriptor.is_none()); + assert!(snapshot_state.snapshot.is_none()); + assert!(snapshot_state.targets_descriptor.is_none()); + assert!(snapshot_state.targets.is_some()); + assert_eq!(advanced.channels, channels); + + let targets_root = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[ + (×tamp_a_id, ×tamp_a_key), + (&snapshot_a_id, &snapshot_a_key), + (&targets_b_id, &targets_b_key), + ], + (&[timestamp_a_id.as_str()], 1), + (&[snapshot_a_id.as_str()], 1), + (&[targets_b_id.as_str()], 1), + ); + verify_root_chain(&[&root_v1, &targets_root]); + let observed = root_transition_high_water(&targets_root).unwrap(); + let advanced = advance_security_floors_through_root_history( + Some(&prior), + channels, + &observed, + &root_history_entries(&[&root_v1, &targets_root]), + ) + .unwrap(); + let targets_state = advanced.repository.unwrap(); + assert!(targets_state.timestamp.is_some()); + assert!(targets_state.snapshot_descriptor.is_some()); + assert!(targets_state.snapshot.is_some()); + assert!(targets_state.targets_descriptor.is_none()); + assert!(targets_state.targets.is_none()); + assert!(advanced.channels.is_empty()); + } + + #[test] + fn every_intermediate_root_transition_taints_floor_provenance() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + + let make_root = |version: u64, ids: &[&str], keys: &[(&str, &Value)]| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + keys, + (ids, 1), + (ids, 1), + (ids, 1), + ) + }; + let root_v1 = make_root(1, &[a_id.as_str()], &[(&a_id, &a_key)]); + let root_v2 = make_root( + 2, + &[a_id.as_str(), b_id.as_str()], + &[(&a_id, &a_key), (&b_id, &b_key)], + ); + let root_v3 = make_root( + 3, + &[b_id.as_str(), c_id.as_str()], + &[(&b_id, &b_key), (&c_id, &c_key)], + ); + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + + let prior = repository_floor_from_root(&root_v1, u64::MAX); + let channels = BTreeMap::from([ + ( + AttestationEnvironment::Production, + CacheHighWater::from_policy(&TrustedReleasePolicy { + environment: AttestationEnvironment::Production, + sequence: 7, + policy_id: "a".repeat(SHA256_HEX_LEN), + repository_high_water: prior.clone(), + valid_until: jiff::Timestamp::MAX, + releases: Vec::new(), + }), + ), + ( + AttestationEnvironment::Development, + CacheHighWater::from_policy(&TrustedReleasePolicy { + environment: AttestationEnvironment::Development, + sequence: 9, + policy_id: "b".repeat(SHA256_HEX_LEN), + repository_high_water: prior.clone(), + valid_until: jiff::Timestamp::MAX, + releases: Vec::new(), + }), + ), + ]); + let observed = root_transition_high_water(&root_v3).unwrap(); + let advanced = advance_security_floors_through_root_history( + Some(&prior), + channels, + &observed, + &root_history_entries(&[&root_v1, &root_v2, &root_v3]), + ) + .unwrap(); + let repository = advanced.repository.unwrap(); + let fingerprint = |key: &Value| { + let parsed: sigstore_tuf::Key = serde_json::from_value(key.clone()).unwrap(); + let verification = parsed.verification_key().unwrap(); + key_custody_fingerprint(&parsed.scheme, verification.as_bytes()).unwrap() + }; + let expected = BTreeSet::from([ + fingerprint(&a_key), + fingerprint(&b_key), + fingerprint(&c_key), + ]); + let assert_tainted = |provenance: &AuthorityProvenance| { + assert_eq!( + provenance + .key_fingerprints + .iter() + .cloned() + .collect::>(), + expected + ); + }; + assert_tainted( + repository + .timestamp + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap(), + ); + assert_tainted( + repository + .snapshot_descriptor + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap(), + ); + assert_tainted( + repository + .snapshot_descriptor + .as_ref() + .unwrap() + .referenced_authority + .as_ref() + .unwrap(), + ); + assert_tainted( + repository + .snapshot + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap(), + ); + assert_tainted( + repository + .targets_descriptor + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap(), + ); + assert_tainted( + repository + .targets_descriptor + .as_ref() + .unwrap() + .referenced_authority + .as_ref() + .unwrap(), + ); + assert_tainted( + repository + .targets + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap(), + ); + for channel in advanced.channels.values() { + assert_tainted(&channel.authority); + } + } + + #[tokio::test] + async fn first_complete_refresh_records_intermediate_retired_online_keys() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + + let make_root = |version: u64, id: &str, key: &Value| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + &[(id, key)], + (&[id], 1), + (&[id], 1), + (&[id], 1), + ) + }; + let root_v1 = make_root(1, &a_id, &a_key); + let root_v2 = make_root(2, &b_id, &b_key); + let root_v3 = make_root(3, &c_id, &c_key); + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + + let (mut repository, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + false, + true, + &c_pair, + 1, + 7, + true, + ); + repository + .metadata + .insert("2.root.json".to_string(), root_v2.clone()); + repository + .metadata + .insert("3.root.json".to_string(), root_v3.clone()); + let store = Arc::new(SnapshotStore::default()); + let now = "2026-08-29T00:00:00Z".parse().unwrap(); + let policy = resolve_policy_with_final_time( + repository, + Arc::clone(&store), + &root_v1, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + || now, + ) + .await + .expect("the first complete refresh should traverse the root chain"); + + let expected = BTreeSet::from([ + tuf_key_fingerprint(&a_key), + tuf_key_fingerprint(&b_key), + tuf_key_fingerprint(&c_key), + ]); + for history in [ + &policy.repository_high_water.authority_history.timestamp, + &policy.repository_high_water.authority_history.snapshot, + &policy.repository_high_water.authority_history.targets, + ] { + assert_eq!(history.iter().cloned().collect::>(), expected); + } + + let root_v4 = make_root(4, &a_id, &a_key); + verify_root_chain(&[&root_v1, &root_v2, &root_v3, &root_v4]); + let mut entries = store.entries(); + entries.insert("root_history/4.root.json".to_string(), root_v4.clone()); + let error = advance_security_floors_through_root_history( + Some(&policy.repository_high_water), + BTreeMap::from([( + AttestationEnvironment::Production, + CacheHighWater::from_policy(&policy), + )]), + &root_transition_high_water(&root_v4).unwrap(), + &entries, + ) + .err() + .expect("a key retired during the first refresh must not be reauthorized"); + assert!(error + .to_string() + .contains("reauthorizes retired key material")); + } + + #[tokio::test] + async fn first_partial_root_only_refresh_persists_intermediate_retired_keys() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + let make_root = |version: u64, id: &str, key: &Value| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + &[(id, key)], + (&[id], 1), + (&[id], 1), + (&[id], 1), + ) + }; + let root_v1 = make_root(1, &a_id, &a_key); + let root_v2 = make_root(2, &b_id, &b_key); + let root_v3 = make_root(3, &c_id, &c_key); + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + + let repository = MemoryRepository { + metadata: HashMap::from([ + ("2.root.json".to_string(), root_v2.clone()), + ("3.root.json".to_string(), root_v3.clone()), + ]), + targets: HashMap::new(), + }; + let store = Arc::new(SnapshotStore::default()); + let mut updater = Updater::new(repository, &root_v1) + .unwrap() + .with_config(tuf_updater_config()) + .with_store(Arc::clone(&store)); + let now = "2026-08-29T00:00:00Z".parse().unwrap(); + assert!(updater.refresh(now).await.is_err()); + let root_chain = authenticated_root_authority_history( + &root_v1, + &store.entries(), + updater.trusted().root().version, + updater.trusted().root_bytes(), + ) + .unwrap(); + assert!(root_chain.error.is_none()); + let repository_high_water = partial_repository_high_water( + &updater, + &store, + root_chain.repository.authority_history, + ) + .unwrap(); + assert!(repository_high_water.timestamp.is_none()); + + let cache_dir = tempfile::tempdir().unwrap(); + let cache_path = cache_dir.path().join("root-only.json"); + let repository_id = "a".repeat(SHA256_HEX_LEN); + persist_cache( + &cache_path, + &repository_id, + &repository_high_water, + &BTreeMap::new(), + &store.entries(), + ) + .unwrap(); + let mut restarted = read_cache(&cache_path, &repository_id).unwrap(); + let root_v4 = make_root(4, &a_id, &a_key); + verify_root_chain(&[&root_v1, &root_v2, &root_v3, &root_v4]); + restarted + .entries + .insert("root_history/4.root.json".to_string(), root_v4.clone()); + let error = advance_security_floors_through_root_history( + restarted.repository_high_water.as_ref(), + restarted.channel_high_water, + &root_transition_high_water(&root_v4).unwrap(), + &restarted.entries, + ) + .err() + .expect("restart must preserve keys retired by a root-only first refresh"); + assert!(error + .to_string() + .contains("reauthorizes retired key material")); + } + + #[test] + fn first_root_chain_rejects_retired_and_cross_role_key_reuse() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + let d_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (d_id, d_key) = tuf_key_entry(&d_pair); + + let same_role_root = |version: u64, id: &str, key: &Value| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + &[(id, key)], + (&[id], 1), + (&[id], 1), + (&[id], 1), + ) + }; + let root_v1 = same_role_root(1, &a_id, &a_key); + let root_v2 = same_role_root(2, &b_id, &b_key); + let root_v3 = same_role_root(3, &a_id, &a_key); + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + let root_chain = authenticated_root_authority_history( + &root_v1, + &root_history_entries(&[&root_v1, &root_v2, &root_v3]), + 3, + &root_v3, + ) + .unwrap(); + let error = root_chain.error.unwrap(); + assert!(error + .to_string() + .contains("reauthorizes retired key material")); + + let cross_role_v1 = tuf_root_with_role_bindings( + 1, + &root_id, + &root_key, + &root_pair, + &[(&a_id, &a_key), (&b_id, &b_key), (&c_id, &c_key)], + (&[a_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[c_id.as_str()], 1), + ); + let cross_role_v2 = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[(&d_id, &d_key), (&b_id, &b_key), (&c_id, &c_key)], + (&[d_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[c_id.as_str()], 1), + ); + let cross_role_v3 = tuf_root_with_role_bindings( + 3, + &root_id, + &root_key, + &root_pair, + &[(&d_id, &d_key), (&b_id, &b_key), (&a_id, &a_key)], + (&[d_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[a_id.as_str()], 1), + ); + verify_root_chain(&[&cross_role_v1, &cross_role_v2, &cross_role_v3]); + let root_chain = authenticated_root_authority_history( + &cross_role_v1, + &root_history_entries(&[&cross_role_v1, &cross_role_v2, &cross_role_v3]), + 3, + &cross_role_v3, + ) + .unwrap(); + let error = root_chain.error.unwrap(); + assert!(error + .to_string() + .contains("reauthorizes retired key material")); + } + + #[tokio::test] + async fn invalid_first_root_chain_journals_longest_valid_prefix_across_restart() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + let make_root = |version: u64, id: &str, key: &Value| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + &[(id, key)], + (&[id], 1), + (&[id], 1), + (&[id], 1), + ) + }; + let root_v1 = make_root(1, &a_id, &a_key); + let root_v2 = make_root(2, &b_id, &b_key); + let rejected_root_v3 = make_root(3, &a_id, &a_key); + verify_root_chain(&[&root_v1, &root_v2, &rejected_root_v3]); + + let store = Arc::new(SnapshotStore::from_entries(root_history_entries(&[ + &root_v1, + &root_v2, + &rejected_root_v3, + ]))); + let now = "2026-08-29T00:00:00Z".parse().unwrap(); + let observation = capture_authenticated_observation(Arc::clone(&store), &root_v1, now) + .await + .unwrap(); + assert_eq!(observation.repository_high_water.root.version, 2); + assert!(observation.repository_high_water.timestamp.is_none()); + assert!(observation.channel_high_water.is_empty()); + assert!(observation + .error + .as_ref() + .unwrap() + .to_string() + .contains("reauthorizes retired key material")); + assert!(observation.entries.contains_key("root_history/2.root.json")); + assert!(!observation.entries.contains_key("root_history/3.root.json")); + let retained_root = + root_transition_high_water(observation.entries.get("root.json").unwrap()).unwrap(); + assert_eq!(retained_root.root.version, 2); + + let cache_dir = tempfile::tempdir().unwrap(); + let cache_path = cache_dir.path().join("valid-prefix.json"); + let repository_id = "a".repeat(SHA256_HEX_LEN); + persist_cache( + &cache_path, + &repository_id, + &observation.repository_high_water, + &observation.channel_high_water, + &observation.entries, + ) + .unwrap(); + let restarted = read_cache(&cache_path, &repository_id).unwrap(); + let replay_error = enforce_repository_high_water( + &root_transition_high_water(&root_v1).unwrap(), + restarted.repository_high_water.as_ref(), + ) + .unwrap_err(); + assert!(replay_error.to_string().contains("root metadata rollback")); + + let corrected_root_v3 = make_root(3, &c_id, &c_key); + verify_root_chain(&[&root_v1, &root_v2, &corrected_root_v3]); + let mut corrected_entries = restarted.entries; + corrected_entries.insert( + "root_history/3.root.json".to_string(), + corrected_root_v3.clone(), + ); + let recovered = advance_security_floors_through_root_history( + restarted.repository_high_water.as_ref(), + restarted.channel_high_water, + &root_transition_high_water(&corrected_root_v3).unwrap(), + &corrected_entries, + ) + .expect("a corrected root at the rejected version should recover"); + assert_eq!(recovered.repository.unwrap().root.version, 3); + } + + #[test] + fn root_key_material_must_be_disjoint_from_online_roles() { + let shared_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (shared_id, shared_key) = tuf_key_entry(&shared_pair); + let root = tuf_root_with_custom_root_role( + 1, + &[(&shared_id, &shared_key)], + (&[shared_id.as_str()], 1), + (&[shared_id.as_str()], 1), + (&[shared_id.as_str()], 1), + (&[shared_id.as_str()], 1), + &[(&shared_id, &shared_pair)], + ); + let trusted = sigstore_tuf::TrustedMetadataSet::from_root(&root).unwrap(); + + let error = match root_role_authorities(trusted.root()) { + Err(error) => error, + Ok(_) => panic!("shared root and online key material was accepted"), + }; + assert!(error + .to_string() + .contains("root role key material must be disjoint")); + } + + #[test] + fn root_key_alias_cannot_hide_online_key_reuse() { + let shared_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, shared_key) = tuf_key_entry(&shared_pair); + let online_alias = format!("online-alias-{root_id}"); + let root = tuf_root_with_custom_root_role( + 1, + &[(&root_id, &shared_key), (&online_alias, &shared_key)], + (&[root_id.as_str()], 1), + (&[online_alias.as_str()], 1), + (&[online_alias.as_str()], 1), + (&[online_alias.as_str()], 1), + &[(&root_id, &shared_pair)], + ); + let trusted = sigstore_tuf::TrustedMetadataSet::from_root(&root).unwrap(); + + let error = match root_role_authorities(trusted.root()) { + Err(error) => error, + Ok(_) => panic!("aliased root key material was accepted online"), + }; + assert!(error + .to_string() + .contains("root role key material must be disjoint")); + } + + #[test] + fn online_roles_may_share_key_material_when_root_is_separate() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_id, online_key) = tuf_key_entry(&online_pair); + let root = tuf_root(1, &root_id, &root_key, &root_pair, &online_id, &online_key); + let trusted = sigstore_tuf::TrustedMetadataSet::from_root(&root).unwrap(); + + let authorities = root_role_authorities(trusted.root()).unwrap(); + assert_eq!(authorities.timestamp, authorities.snapshot); + assert_eq!(authorities.snapshot, authorities.targets); + } + + #[tokio::test] + async fn root_and_online_custody_classes_cannot_cross_across_history_or_restart() { + let r_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (r_id, r_key) = tuf_key_entry(&r_pair); + let s_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (s_id, s_key) = tuf_key_entry(&s_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + + let root_v1 = tuf_root_with_custom_root_role( + 1, + &[(&r_id, &r_key), (&a_id, &a_key)], + (&[r_id.as_str()], 1), + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + &[(&r_id, &r_pair)], + ); + let root_v2 = tuf_root_with_custom_root_role( + 2, + &[ + (&r_id, &r_key), + (&s_id, &s_key), + (&a_id, &a_key), + (&b_id, &b_key), + ], + (&[s_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + &[(&r_id, &r_pair), (&s_id, &s_pair)], + ); + verify_root_chain(&[&root_v1, &root_v2]); + let history_v2 = root_history_entries(&[&root_v1, &root_v2]); + let chain_v2 = + authenticated_root_authority_history(&root_v1, &history_v2, 2, &root_v2).unwrap(); + assert!(chain_v2.error.is_none()); + assert_eq!(chain_v2.repository.root.version, 2); + + // A direct R/A swap is individually disjoint in root v2 and validly + // cross-signed, but violates the repository-lifetime custody classes. + let swapped_v2 = tuf_root_with_custom_root_role( + 2, + &[(&r_id, &r_key), (&a_id, &a_key)], + (&[a_id.as_str()], 1), + (&[r_id.as_str()], 1), + (&[r_id.as_str()], 1), + (&[r_id.as_str()], 1), + &[(&r_id, &r_pair), (&a_id, &a_pair)], + ); + verify_root_chain(&[&root_v1, &swapped_v2]); + let swapped = authenticated_root_authority_history( + &root_v1, + &root_history_entries(&[&root_v1, &swapped_v2]), + 2, + &swapped_v2, + ) + .unwrap(); + assert_eq!(swapped.repository.root.version, 1); + assert!(swapped + .error + .unwrap() + .to_string() + .contains("key custody class violation")); + + let swapped_store = Arc::new(SnapshotStore::from_entries(root_history_entries(&[ + &root_v1, + &swapped_v2, + ]))); + let observation = capture_authenticated_observation( + swapped_store, + &root_v1, + "2026-08-29T00:00:00Z".parse().unwrap(), + ) + .await + .unwrap(); + assert_eq!(observation.repository_high_water.root.version, 1); + assert!(observation.repository_high_water.timestamp.is_none()); + assert!(observation + .error + .unwrap() + .to_string() + .contains("key custody class violation")); + assert!(!observation.entries.contains_key("root_history/2.root.json")); + + let directory = tempfile::tempdir().unwrap(); + let cache_path = directory.path().join("custody-ledger.json"); + let repository_id = repository_id(REPOSITORY_URL); + let mut persisted_entries = history_v2; + persisted_entries.insert("root.json".to_string(), root_v2.clone()); + persist_cache( + &cache_path, + &repository_id, + &chain_v2.repository, + &BTreeMap::new(), + &persisted_entries, + ) + .unwrap(); + let restarted = read_cache(&cache_path, &repository_id).unwrap(); + + let root_alias = format!("root-alias-{r_id}"); + let prior_root_becomes_online = tuf_root_with_custom_root_role( + 3, + &[(&s_id, &s_key), (&root_alias, &r_key), (&b_id, &b_key)], + (&[s_id.as_str()], 1), + (&[root_alias.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + &[(&s_id, &s_pair)], + ); + verify_root_chain(&[&root_v1, &root_v2, &prior_root_becomes_online]); + + let online_alias = format!("online-alias-{a_id}"); + let prior_online_becomes_root = tuf_root_with_custom_root_role( + 3, + &[(&s_id, &s_key), (&online_alias, &a_key), (&b_id, &b_key)], + (&[online_alias.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + &[(&s_id, &s_pair), (&online_alias, &a_pair)], + ); + verify_root_chain(&[&root_v1, &root_v2, &prior_online_becomes_root]); + + for rejected in [prior_root_becomes_online, prior_online_becomes_root] { + let mut entries = restarted.entries.clone(); + entries.insert("root_history/3.root.json".to_string(), rejected.clone()); + let error = advance_security_floors_through_root_history( + restarted.repository_high_water.as_ref(), + restarted.channel_high_water.clone(), + &root_transition_high_water(&rejected).unwrap(), + &entries, + ) + .err() + .expect("crossing a historical custody class must fail"); + assert!(error.to_string().contains("key custody class violation")); + } + } + + #[test] + fn intermediate_root_with_duplicate_key_material_is_rejected() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let alias_id = format!("alias-{a_id}"); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let root_v1 = tuf_root_with_role_bindings( + 1, + &root_id, + &root_key, + &root_pair, + &[(&a_id, &a_key)], + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + ); + let root_v2 = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[(&a_id, &a_key), (&alias_id, &a_key)], + (&[a_id.as_str(), alias_id.as_str()], 2), + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + ); + let root_v3 = tuf_root_with_role_bindings( + 3, + &root_id, + &root_key, + &root_pair, + &[(&b_id, &b_key)], + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + ); + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + let entries = BTreeMap::from([ + ("root_history/1.root.json".to_string(), root_v1), + ("root_history/2.root.json".to_string(), root_v2), + ("root_history/3.root.json".to_string(), root_v3), + ]); + let error = validate_authenticated_root_history(&entries, 3).unwrap_err(); + assert!(error.to_string().contains("duplicate aliases")); + } + + #[test] + fn intermediate_root_role_alias_threshold_is_rejected_even_if_final_root_is_clean() { + let root_a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_a_id, root_a_key) = tuf_key_entry(&root_a_pair); + let root_a_alias_1 = format!("alias-1-{root_a_id}"); + let root_a_alias_2 = format!("alias-2-{root_a_id}"); + let root_b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_b_id, root_b_key) = tuf_key_entry(&root_b_pair); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_id, online_key) = tuf_key_entry(&online_pair); + + let root_v1 = tuf_root_with_custom_root_role( + 1, + &[(&root_a_id, &root_a_key), (&online_id, &online_key)], + (&[root_a_id.as_str()], 1), + (&[online_id.as_str()], 1), + (&[online_id.as_str()], 1), + (&[online_id.as_str()], 1), + &[(&root_a_id, &root_a_pair)], + ); + let root_v2 = tuf_root_with_custom_root_role( + 2, + &[ + (&root_a_id, &root_a_key), + (&root_a_alias_1, &root_a_key), + (&root_a_alias_2, &root_a_key), + (&online_id, &online_key), + ], + (&[root_a_alias_1.as_str(), root_a_alias_2.as_str()], 2), + (&[online_id.as_str()], 1), + (&[online_id.as_str()], 1), + (&[online_id.as_str()], 1), + &[ + (&root_a_id, &root_a_pair), + (&root_a_alias_1, &root_a_pair), + (&root_a_alias_2, &root_a_pair), + ], + ); + let root_v3 = tuf_root_with_custom_root_role( + 3, + &[(&root_b_id, &root_b_key), (&online_id, &online_key)], + (&[root_b_id.as_str()], 1), + (&[online_id.as_str()], 1), + (&[online_id.as_str()], 1), + (&[online_id.as_str()], 1), + &[ + (&root_a_alias_1, &root_a_pair), + (&root_a_alias_2, &root_a_pair), + (&root_b_id, &root_b_pair), + ], + ); + + // sigstore-tuf 0.11 counts distinct declared key IDs, so it accepts + // root v2's two aliases as a threshold of two and lets that malformed + // intermediate root authenticate the otherwise-clean root v3. + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + let entries = BTreeMap::from([ + ("root_history/1.root.json".to_string(), root_v1), + ("root_history/2.root.json".to_string(), root_v2), + ("root_history/3.root.json".to_string(), root_v3), + ]); + let error = validate_authenticated_root_history(&entries, 3).unwrap_err(); + assert!(error.to_string().contains("role 'root'")); + assert!(error.to_string().contains("duplicate aliases")); + } + + #[test] + fn retired_online_key_material_cannot_be_reauthorized() { + let mut prior = RepositoryHighWater::for_test(); + let retired = prior.timestamp_authority.clone(); + let replacement = RoleAuthority::for_test('e'); + prior.timestamp_authority = replacement.clone(); + prior.authority_history.timestamp = merge_authority_key_history( + "timestamp", + &retired.key_fingerprints, + &replacement.key_fingerprints, + ) + .unwrap(); + prior.timestamp = None; + prior.snapshot_descriptor = None; + + let mut observed = prior.clone(); + observed.root.version += 1; + observed.root.sha256 = "f".repeat(SHA256_HEX_LEN); + observed.timestamp_authority = RoleAuthority { + threshold: 2, + key_fingerprints: prior.authority_history.timestamp.clone(), + }; + observed.authority_history = AuthorityHistory::from_authorities( + &observed.root_authority, + &observed.timestamp_authority, + &observed.snapshot_authority, + &observed.targets_authority, + ); + let merged = merge_repository_observation(Some(&prior), &observed); + assert!(merged + .error + .unwrap() + .to_string() + .contains("reauthorizes retired key material")); + } + + #[test] + fn retired_key_cannot_return_in_another_online_role_after_safe_replacement() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + let d_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (d_id, d_key) = tuf_key_entry(&d_pair); + + let root_v1 = tuf_root_with_role_bindings( + 1, + &root_id, + &root_key, + &root_pair, + &[(&a_id, &a_key), (&b_id, &b_key), (&c_id, &c_key)], + (&[a_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[c_id.as_str()], 1), + ); + let root_v2 = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[(&d_id, &d_key), (&b_id, &b_key), (&c_id, &c_key)], + (&[d_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[c_id.as_str()], 1), + ); + let root_v3 = tuf_root_with_role_bindings( + 3, + &root_id, + &root_key, + &root_pair, + &[(&d_id, &d_key), (&b_id, &b_key), (&a_id, &a_key)], + (&[d_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[a_id.as_str()], 1), + ); + verify_root_chain(&[&root_v1, &root_v2, &root_v3]); + + let initial = repository_floor_from_root(&root_v1, 7); + let root_v2_floor = root_transition_high_water(&root_v2).unwrap(); + let advanced = advance_security_floors_through_root_history( + Some(&initial), + BTreeMap::new(), + &root_v2_floor, + &root_history_entries(&[&root_v1, &root_v2]), + ) + .unwrap() + .repository + .unwrap(); + let root_v3_floor = root_transition_high_water(&root_v3).unwrap(); + let error = advance_security_floors_through_root_history( + Some(&advanced), + BTreeMap::new(), + &root_v3_floor, + &root_history_entries(&[&root_v1, &root_v2, &root_v3]), + ) + .err() + .expect("retired cross-role key must be rejected"); + assert!(error + .to_string() + .contains("reauthorizes retired key material")); + } + + #[test] + fn root_history_must_anchor_to_the_exact_in_memory_fork() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let c_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (c_id, c_key) = tuf_key_entry(&c_pair); + + let make_root = |version: u64, ids: &[&str], keys: &[(&str, &Value)]| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + keys, + (ids, 1), + (ids, 1), + (ids, 1), + ) + }; + let root_v1 = make_root(1, &[a_id.as_str()], &[(&a_id, &a_key)]); + let root_v2_memory = make_root( + 2, + &[a_id.as_str(), b_id.as_str()], + &[(&a_id, &a_key), (&b_id, &b_key)], + ); + let root_v2_fork = make_root( + 2, + &[a_id.as_str(), c_id.as_str()], + &[(&a_id, &a_key), (&c_id, &c_key)], + ); + let root_v3_fork = make_root(3, &[c_id.as_str()], &[(&c_id, &c_key)]); + verify_root_chain(&[&root_v1, &root_v2_memory]); + verify_root_chain(&[&root_v1, &root_v2_fork, &root_v3_fork]); + + let initial = repository_floor_from_root(&root_v1, 7); + let memory = advance_security_floors_through_root_history( + Some(&initial), + BTreeMap::new(), + &root_transition_high_water(&root_v2_memory).unwrap(), + &root_history_entries(&[&root_v1, &root_v2_memory]), + ) + .unwrap() + .repository + .unwrap(); + let error = advance_security_floors_through_root_history( + Some(&memory), + BTreeMap::new(), + &root_transition_high_water(&root_v3_fork).unwrap(), + &root_history_entries(&[&root_v1, &root_v2_fork, &root_v3_fork]), + ) + .err() + .expect("root-history fork must be rejected"); + assert!(error + .to_string() + .contains("forks from in-memory root version 2")); + } + + #[test] + fn root_history_transition_must_be_cross_signed_by_its_predecessor() { + let root_a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_a_id, root_a_key) = tuf_key_entry(&root_a_pair); + let root_b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_b_id, root_b_key) = tuf_key_entry(&root_b_pair); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_id, online_key) = tuf_key_entry(&online_pair); + let root_v1 = tuf_root( + 1, + &root_a_id, + &root_a_key, + &root_a_pair, + &online_id, + &online_key, + ); + // This root is internally well formed and self-signed, but root v1 + // never authorized root-B, so it is not a valid TUF transition. + let root_v2 = tuf_root( + 2, + &root_b_id, + &root_b_key, + &root_b_pair, + &online_id, + &online_key, + ); + let error = advance_security_floors_through_root_history( + Some(&repository_floor_from_root(&root_v1, 7)), + BTreeMap::new(), + &root_transition_high_water(&root_v2).unwrap(), + &root_history_entries(&[&root_v1, &root_v2]), + ) + .err() + .expect("self-signed non-transitioning root must be rejected"); + assert!(error + .to_string() + .contains("not authenticated by the preceding root")); + } + + #[test] + fn root_history_cannot_skip_an_intermediate_version() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (online_id, online_key) = tuf_key_entry(&online_pair); + let root_v1 = tuf_root(1, &root_id, &root_key, &root_pair, &online_id, &online_key); + let root_v3 = tuf_root(3, &root_id, &root_key, &root_pair, &online_id, &online_key); + let entries = root_history_entries(&[&root_v1, &root_v3]); + + let error = advance_security_floors_through_root_history( + Some(&repository_floor_from_root(&root_v1, 7)), + BTreeMap::new(), + &root_transition_high_water(&root_v3).unwrap(), + &entries, + ) + .err() + .expect("a root chain missing version 2 must fail closed"); + + assert!(error + .to_string() + .contains("missing authenticated root transition 2")); + } + + #[test] + fn memory_root_history_reconciles_newer_disk_child_and_channel_floors() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let make_root = |version: u64, ids: &[&str], keys: &[(&str, &Value)]| { + tuf_root_with_role_bindings( + version, + &root_id, + &root_key, + &root_pair, + keys, + (ids, 1), + (ids, 1), + (ids, 1), + ) + }; + let root_v1 = make_root(1, &[a_id.as_str()], &[(&a_id, &a_key)]); + let root_v2 = make_root( + 2, + &[a_id.as_str(), b_id.as_str()], + &[(&a_id, &a_key), (&b_id, &b_key)], + ); + verify_root_chain(&[&root_v1, &root_v2]); + + let cached_repository = repository_floor_from_root(&root_v1, 99); + let cached_channel = CacheHighWater::from_policy(&TrustedReleasePolicy { + environment: AttestationEnvironment::Production, + sequence: 12, + policy_id: "a".repeat(SHA256_HEX_LEN), + repository_high_water: cached_repository.clone(), + valid_until: jiff::Timestamp::MAX, + releases: Vec::new(), + }); + let memory_initial = repository_floor_from_root(&root_v1, 7); + let history = root_history_entries(&[&root_v1, &root_v2]); + let memory = advance_security_floors_through_root_history( + Some(&memory_initial), + BTreeMap::new(), + &root_transition_high_water(&root_v2).unwrap(), + &history, + ) + .unwrap() + .repository + .unwrap(); + + let merged = merge_loaded_security_high_water_states( + Some(&cached_repository), + BTreeMap::from([(AttestationEnvironment::Production, cached_channel)]), + Some(&memory), + &BTreeMap::new(), + &root_history_entries(&[&root_v1]), + &history, + ) + .unwrap(); + let repository = merged.repository.unwrap(); + assert_eq!(repository.root.version, 2); + assert_eq!(repository.timestamp.as_ref().unwrap().version, 99); + assert_eq!(repository.snapshot.as_ref().unwrap().version, 99); + assert_eq!(repository.targets.as_ref().unwrap().version, 99); + assert_eq!( + merged + .channels + .get(&AttestationEnvironment::Production) + .unwrap() + .sequence, + 12 + ); + assert_eq!( + repository + .targets + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap() + .key_fingerprints + .len(), + 2 + ); + } + + #[test] + fn memory_root_history_reconciliation_rejects_an_older_disk_fork() { + let root_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (root_id, root_key) = tuf_key_entry(&root_pair); + let a_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (a_id, a_key) = tuf_key_entry(&a_pair); + let b_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (b_id, b_key) = tuf_key_entry(&b_pair); + let root_v1 = tuf_root_with_role_bindings( + 1, + &root_id, + &root_key, + &root_pair, + &[(&a_id, &a_key)], + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + (&[a_id.as_str()], 1), + ); + let root_v1_fork = tuf_root_with_role_bindings( + 1, + &root_id, + &root_key, + &root_pair, + &[(&b_id, &b_key)], + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + (&[b_id.as_str()], 1), + ); + let root_v2 = tuf_root_with_role_bindings( + 2, + &root_id, + &root_key, + &root_pair, + &[(&a_id, &a_key), (&b_id, &b_key)], + (&[a_id.as_str(), b_id.as_str()], 1), + (&[a_id.as_str(), b_id.as_str()], 1), + (&[a_id.as_str(), b_id.as_str()], 1), + ); + verify_root_chain(&[&root_v1, &root_v2]); + let history = root_history_entries(&[&root_v1, &root_v2]); + let memory = advance_security_floors_through_root_history( + Some(&repository_floor_from_root(&root_v1, 7)), + BTreeMap::new(), + &root_transition_high_water(&root_v2).unwrap(), + &history, + ) + .unwrap() + .repository + .unwrap(); + let error = merge_loaded_security_high_water_states( + Some(&repository_floor_from_root(&root_v1_fork, 99)), + BTreeMap::new(), + Some(&memory), + &BTreeMap::new(), + &root_history_entries(&[&root_v1_fork]), + &history, + ) + .err() + .expect("forked disk anchor must be rejected"); + assert!(error + .to_string() + .contains("forks from in-memory root version 1")); + } + + #[test] + fn semantic_metadata_hash_ignores_signature_and_encoding_variants() { + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (key_id, _) = tuf_key_entry(&key_pair); + let signed = json!({ + "_type": "timestamp", + "expires": "2027-01-01T00:00:00Z", + "meta": {"snapshot.json": {"version": 7}}, + "spec_version": "1.0.0", + "version": 9, + }); + let signature = tuf_signature(&signed, &key_id, &key_pair); + let extra_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (extra_id, _) = tuf_key_entry(&extra_pair); + let extra = tuf_signature(&signed, &extra_id, &extra_pair); + let compact = serde_json::to_vec(&json!({ + "signatures": [signature.clone()], + "signed": signed.clone(), + })) + .unwrap(); + let reordered = serde_json::to_string_pretty(&json!({ + "signed": signed, + "signatures": [extra, signature], + })) + .unwrap(); + assert_eq!( + signed_metadata_sha256("timestamp", &compact).unwrap(), + signed_metadata_sha256("timestamp", reordered.as_bytes()).unwrap() + ); + } + + #[test] + fn equal_floor_merges_union_provenance_commutatively_and_bound_growth() { + let left = RepositoryHighWater::for_test(); + let mut right = left.clone(); + right.timestamp.as_mut().unwrap().authority = Some(AuthorityProvenance { + key_fingerprints: RoleAuthority::for_test('e').key_fingerprints, + }); + right + .snapshot_descriptor + .as_mut() + .unwrap() + .referenced_authority = Some(AuthorityProvenance { + key_fingerprints: RoleAuthority::for_test('f').key_fingerprints, + }); + let left_right = merge_repository_high_waters(Some(&left), Some(&right)) + .unwrap() + .unwrap(); + let right_left = merge_repository_high_waters(Some(&right), Some(&left)) + .unwrap() + .unwrap(); + assert_eq!(left_right, right_left); + assert_eq!( + left_right + .timestamp + .as_ref() + .unwrap() + .authority + .as_ref() + .unwrap() + .key_fingerprints + .len(), + 2 + ); + assert_eq!( + left_right + .snapshot_descriptor + .as_ref() + .unwrap() + .referenced_authority + .as_ref() + .unwrap() + .key_fingerprints + .len(), + 2 + ); + + let left_channel = CacheHighWater::for_test(7, 'a', 'd'); + let right_channel = CacheHighWater::for_test(7, 'a', 'e'); + assert_eq!( + merge_high_water(Some(&left_channel), Some(&right_channel)).unwrap(), + merge_high_water(Some(&right_channel), Some(&left_channel)).unwrap() + ); + + let full = AuthorityProvenance { + key_fingerprints: (0..MAX_AUTHORITY_KEYS) + .map(|index| format!("{index:064x}")) + .collect(), + }; + let extra = RoleAuthority { + threshold: 1, + key_fingerprints: vec![format!("{:064x}", MAX_AUTHORITY_KEYS)], + }; + assert!(union_authority_provenance(&full, &extra).is_err()); + } + + #[test] + fn authenticated_observation_journals_only_the_longest_accepted_role_chain() { + let mark = |version: u64, byte: char| MetadataHighWater { + version, + sha256: byte.to_string().repeat(SHA256_HEX_LEN), + authority: None, + referenced_authority: None, + }; + let role_mark = |version: u64, byte: char, authority: &RoleAuthority| MetadataHighWater { + version, + sha256: byte.to_string().repeat(SHA256_HEX_LEN), + authority: Some(authority.into()), + referenced_authority: None, + }; + let descriptor_mark = |version: u64, + byte: char, + authority: &RoleAuthority, + referenced_authority: &RoleAuthority| + -> MetadataHighWater { + MetadataHighWater { + version, + sha256: byte.to_string().repeat(SHA256_HEX_LEN), + authority: Some(authority.into()), + referenced_authority: Some(referenced_authority.into()), + } + }; + let timestamp_authority = RoleAuthority::for_test('1'); + let snapshot_authority = RoleAuthority::for_test('2'); + let targets_authority = RoleAuthority::for_test('3'); + let root_authority = RoleAuthority::for_test('4'); + let prior = RepositoryHighWater { + root: mark(1, 'a'), + root_authority: root_authority.clone(), + timestamp_authority: timestamp_authority.clone(), + snapshot_authority: snapshot_authority.clone(), + targets_authority: targets_authority.clone(), + authority_history: AuthorityHistory::from_authorities( + &root_authority, + ×tamp_authority, + &snapshot_authority, + &targets_authority, + ), + timestamp: Some(role_mark(10, 'b', ×tamp_authority)), + snapshot_descriptor: Some(descriptor_mark( + 10, + 'e', + ×tamp_authority, + &snapshot_authority, + )), + snapshot: Some(role_mark(10, 'c', &snapshot_authority)), + targets_descriptor: Some(descriptor_mark( + 10, + 'e', + &snapshot_authority, + &targets_authority, + )), + targets: Some(role_mark(10, 'd', &targets_authority)), + }; + + let rejected_root = RepositoryHighWater { + root: mark(1, 'e'), + root_authority: prior.root_authority.clone(), + timestamp_authority: prior.timestamp_authority.clone(), + snapshot_authority: prior.snapshot_authority.clone(), + targets_authority: prior.targets_authority.clone(), + authority_history: prior.authority_history.clone(), + timestamp: Some(role_mark(u64::MAX, 'f', ×tamp_authority)), + snapshot_descriptor: Some(descriptor_mark( + u64::MAX, + 'f', + ×tamp_authority, + &snapshot_authority, + )), + snapshot: Some(role_mark(u64::MAX, 'f', &snapshot_authority)), + targets_descriptor: Some(descriptor_mark( + u64::MAX, + 'f', + &snapshot_authority, + &targets_authority, + )), + targets: Some(role_mark(u64::MAX, 'f', &targets_authority)), + }; + let merged = merge_repository_observation(Some(&prior), &rejected_root); + assert!(merged.error.is_some()); + assert!(!merged.accepted_through_targets); + assert_eq!(merged.high_water, prior); + + let rejected_snapshot = RepositoryHighWater { + root: mark(2, 'e'), + root_authority: prior.root_authority.clone(), + timestamp_authority: prior.timestamp_authority.clone(), + snapshot_authority: prior.snapshot_authority.clone(), + targets_authority: prior.targets_authority.clone(), + authority_history: prior.authority_history.clone(), + timestamp: Some(role_mark(11, 'f', ×tamp_authority)), + snapshot_descriptor: Some(descriptor_mark( + 11, + 'f', + ×tamp_authority, + &snapshot_authority, + )), + snapshot: Some(role_mark(9, 'a', &snapshot_authority)), + targets_descriptor: Some(descriptor_mark( + u64::MAX, + 'f', + &snapshot_authority, + &targets_authority, + )), + targets: Some(role_mark(u64::MAX, 'f', &targets_authority)), + }; + let merged = merge_repository_observation(Some(&prior), &rejected_snapshot); + assert!(merged.error.is_some()); + assert!(!merged.accepted_through_targets); + assert_eq!(merged.high_water.root, rejected_snapshot.root); + assert_eq!(merged.high_water.timestamp, rejected_snapshot.timestamp); + assert_eq!(merged.high_water.snapshot, prior.snapshot); + assert_eq!(merged.high_water.targets, prior.targets); + + let accepted = RepositoryHighWater { + root: mark(2, 'e'), + root_authority: prior.root_authority.clone(), + timestamp_authority: prior.timestamp_authority.clone(), + snapshot_authority: prior.snapshot_authority.clone(), + targets_authority: prior.targets_authority.clone(), + authority_history: prior.authority_history.clone(), + timestamp: Some(role_mark(11, 'f', ×tamp_authority)), + snapshot_descriptor: Some(descriptor_mark( + 11, + 'f', + ×tamp_authority, + &snapshot_authority, + )), + snapshot: Some(role_mark(11, 'f', &snapshot_authority)), + targets_descriptor: Some(descriptor_mark( + 11, + 'f', + &snapshot_authority, + &targets_authority, + )), + targets: Some(role_mark(11, 'f', &targets_authority)), + }; + let merged = merge_repository_observation(Some(&prior), &accepted); + assert!(merged.error.is_none()); + assert!(merged.accepted_through_targets); + assert_eq!(merged.high_water, accepted); + } + + #[test] + fn portable_bundle_verification_is_fully_local_without_an_identity_gate() { + let bundle = include_bytes!("../tests/fixtures/cosign-v3-blob.sigstore.json"); + let trusted_root = sigstore_verify::trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT.as_bytes(); + let artifact = b"test content for cosign\n"; + PortableBundleVerifier + .verify(artifact, bundle, trusted_root) + .unwrap(); + + assert!(PortableBundleVerifier + .verify(b"tampered", bundle, trusted_root) + .is_err()); + + let mut downgraded: Value = serde_json::from_slice(bundle).unwrap(); + downgraded["mediaType"] = + Value::String("application/vnd.dev.sigstore.bundle+json;version=0.2".to_string()); + assert!(PortableBundleVerifier + .verify( + artifact, + &serde_json::to_vec(&downgraded).unwrap(), + trusted_root, + ) + .is_err()); + + let mut multiple_entries: Value = serde_json::from_slice(bundle).unwrap(); + let duplicate = multiple_entries["verificationMaterial"]["tlogEntries"][0].clone(); + multiple_entries["verificationMaterial"]["tlogEntries"] + .as_array_mut() + .unwrap() + .push(duplicate); + let error = PortableBundleVerifier + .verify( + artifact, + &serde_json::to_vec(&multiple_entries).unwrap(), + trusted_root, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("exactly one transparency-log entry")); + + let mut missing_checkpoint: Value = serde_json::from_slice(bundle).unwrap(); + missing_checkpoint["verificationMaterial"]["tlogEntries"][0]["inclusionProof"] + .as_object_mut() + .unwrap() + .remove("checkpoint"); + let error = PortableBundleVerifier + .verify( + artifact, + &serde_json::to_vec(&missing_checkpoint).unwrap(), + trusted_root, + ) + .unwrap_err(); + assert!(error.to_string().contains("signed checkpoint")); + } + + #[test] + fn portable_bundle_verifies_official_rekor_v2_with_omitted_integrated_time() { + let artifact = include_bytes!("../tests/fixtures/rekor-v2-artifact.txt"); + let bundle = include_bytes!("../tests/fixtures/rekor-v2-bundle.sigstore.fixture"); + let trusted_root = include_bytes!("../tests/fixtures/rekor-v2-trusted-root.fixture"); + let value: Value = serde_json::from_slice(bundle).unwrap(); + assert!(value["verificationMaterial"]["tlogEntries"][0] + .get("integratedTime") + .is_none()); + + PortableBundleVerifier + .verify(artifact, bundle, trusted_root) + .unwrap(); + } + + #[test] + fn portable_bundle_profile_requires_an_exact_sha256_message_digest() { + let fixture = include_bytes!("../tests/fixtures/cosign-v3-blob.sigstore.json"); + + let mut missing: Value = serde_json::from_slice(fixture).unwrap(); + missing["messageSignature"] + .as_object_mut() + .unwrap() + .remove("messageDigest"); + let bundle = parse_portable_bundle(&serde_json::to_string(&missing).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error.to_string().contains("must contain a messageDigest")); + + for algorithm in ["sha256", "SHA2_512"] { + let mut wrong_algorithm: Value = serde_json::from_slice(fixture).unwrap(); + wrong_algorithm["messageSignature"]["messageDigest"]["algorithm"] = + Value::String(algorithm.to_string()); + let error = parse_portable_bundle(&serde_json::to_string(&wrong_algorithm).unwrap()) + .and_then(|bundle| validate_portable_bundle_profile(&bundle)) + .unwrap_err(); + assert!(error + .to_string() + .contains("algorithm must be exactly SHA2_256")); + } + + let mut short_digest: Value = serde_json::from_slice(fixture).unwrap(); + short_digest["messageSignature"]["messageDigest"]["digest"] = + Value::String(BASE64.encode([0_u8; 31])); + let bundle = parse_portable_bundle(&serde_json::to_string(&short_digest).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error.to_string().contains("exactly 32 bytes")); + } + + #[test] + fn portable_bundle_profile_enforces_hashedrekord_v1_time_and_promise() { + let fixture = include_bytes!("../tests/fixtures/cosign-v3-blob.sigstore.json"); + + let mut zero_time: Value = serde_json::from_slice(fixture).unwrap(); + zero_time["verificationMaterial"]["tlogEntries"][0]["integratedTime"] = + Value::String("0".to_string()); + let bundle = parse_portable_bundle(&serde_json::to_string(&zero_time).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error + .to_string() + .contains("integratedTime must be positive")); + + let mut missing_promise: Value = serde_json::from_slice(fixture).unwrap(); + missing_promise["verificationMaterial"]["tlogEntries"][0] + .as_object_mut() + .unwrap() + .remove("inclusionPromise"); + let bundle = + parse_portable_bundle(&serde_json::to_string(&missing_promise).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error.to_string().contains("inclusion promise")); + + let mut missing_timestamp: Value = serde_json::from_slice(fixture).unwrap(); + missing_timestamp["verificationMaterial"]["timestampVerificationData"] + ["rfc3161Timestamps"] = json!([]); + let bundle = + parse_portable_bundle(&serde_json::to_string(&missing_timestamp).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error + .to_string() + .contains("must contain an RFC3161 timestamp")); + + let mut wrong_kind: Value = serde_json::from_slice(fixture).unwrap(); + wrong_kind["verificationMaterial"]["tlogEntries"][0]["kindVersion"]["kind"] = + Value::String("intoto".to_string()); + let bundle = parse_portable_bundle(&serde_json::to_string(&wrong_kind).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error.to_string().contains("must be hashedrekord")); + + let mut unknown_version: Value = serde_json::from_slice(fixture).unwrap(); + unknown_version["verificationMaterial"]["tlogEntries"][0]["kindVersion"]["version"] = + Value::String("0.0.3".to_string()); + let bundle = + parse_portable_bundle(&serde_json::to_string(&unknown_version).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error + .to_string() + .contains("unsupported hashedrekord version '0.0.3'")); + } + + #[test] + fn portable_bundle_profile_accepts_zero_equivalent_rekor_v2_time_forms() { + let fixture = include_bytes!("../tests/fixtures/cosign-v3-blob.sigstore.json"); + for integrated_time in [None, Some(Value::Null), Some(json!(0)), Some(json!("0"))] { + let mut value: Value = serde_json::from_slice(fixture).unwrap(); + value["verificationMaterial"]["tlogEntries"][0]["kindVersion"]["version"] = + Value::String("0.0.2".to_string()); + let entry = value["verificationMaterial"]["tlogEntries"][0] + .as_object_mut() + .unwrap(); + match integrated_time { + Some(integrated_time) => { + entry.insert("integratedTime".to_string(), integrated_time); + } + None => { + entry.remove("integratedTime"); + } + } + + let bundle = parse_portable_bundle(&serde_json::to_string(&value).unwrap()).unwrap(); + assert_eq!( + bundle.verification_material.tlog_entries[0].integrated_time, + 0 + ); + validate_portable_bundle_profile(&bundle).unwrap(); + } + } + + #[test] + fn portable_bundle_profile_rejects_rekor_v2_nonzero_time_or_missing_timestamp() { + let fixture = include_bytes!("../tests/fixtures/cosign-v3-blob.sigstore.json"); + let mut v2: Value = serde_json::from_slice(fixture).unwrap(); + v2["verificationMaterial"]["tlogEntries"][0]["kindVersion"]["version"] = + Value::String("0.0.2".to_string()); + + for integrated_time in [json!(1), json!("1")] { + let mut nonzero = v2.clone(); + nonzero["verificationMaterial"]["tlogEntries"][0]["integratedTime"] = integrated_time; + let error = parse_portable_bundle(&serde_json::to_string(&nonzero).unwrap()) + .and_then(|bundle| validate_portable_bundle_profile(&bundle)) + .unwrap_err(); + assert!(error + .to_string() + .contains("integratedTime must be absent, null, or zero")); + } + + v2["verificationMaterial"]["tlogEntries"][0] + .as_object_mut() + .unwrap() + .remove("integratedTime"); + v2["verificationMaterial"]["timestampVerificationData"]["rfc3161Timestamps"] = json!([]); + let bundle = parse_portable_bundle(&serde_json::to_string(&v2).unwrap()).unwrap(); + let error = validate_portable_bundle_profile(&bundle).unwrap_err(); + assert!(error + .to_string() + .contains("must contain an RFC3161 timestamp")); + } + + #[tokio::test] + async fn repository_does_not_follow_redirects() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/timestamp.json")) + .respond_with( + ResponseTemplate::new(302).insert_header("location", "https://github.com/"), + ) + .mount(&server) + .await; + let repository = HttpTufRepository::new(&format!("{}/tuf/", server.uri()), true).unwrap(); + let error = repository + .fetch_metadata("timestamp.json", 1024) + .await + .unwrap_err(); + assert!(error.to_string().contains("302")); + } + + #[tokio::test] + async fn repository_enforces_stream_size_bound() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/timestamp.json")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0; 32])) + .mount(&server) + .await; + let repository = HttpTufRepository::new(&format!("{}/tuf/", server.uri()), true).unwrap(); + let error = repository + .fetch_metadata("timestamp.json", 8) + .await + .unwrap_err(); + assert!(error.to_string().contains("maximum response length")); + } + + #[tokio::test] + async fn repository_total_deadline_bounds_a_delayed_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tuf/metadata/timestamp.json")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(250)) + .set_body_bytes(b"eventual response"), + ) + .mount(&server) + .await; + let repository = HttpTufRepository::new_with_timeout( + &format!("{}/tuf/", server.uri()), + true, + Duration::from_millis(25), + ) + .unwrap(); + let started = tokio::time::Instant::now(); + let error = repository + .fetch_metadata("timestamp.json", 1024) + .await + .unwrap_err(); + assert!(error.to_string().contains(TUF_UNAVAILABLE_PREFIX)); + assert!(started.elapsed() < Duration::from_secs(1)); + } +} diff --git a/sdk/rust/tests/attestation.rs b/sdk/rust/tests/attestation.rs index e1500aa5c..3e3a077e0 100644 --- a/sdk/rust/tests/attestation.rs +++ b/sdk/rust/tests/attestation.rs @@ -1,8 +1,9 @@ mod common; -use opensecret::{Error, OpenSecretClient, Pcr0Environment, Result}; +use opensecret::{AttestationEnvironment, Error, OpenSecretClient, Result, TrustedReleaseConfig}; use std::env; +#[cfg(feature = "mock-attestation")] #[tokio::test] async fn test_attestation_handshake_localhost() -> Result<()> { // Skip if not running against localhost @@ -45,7 +46,6 @@ async fn test_attestation_handshake_hosted_selected_environment() -> Result<()> return Ok(()); } - let pcr0_environment = common::selected_pcr0_environment()?; let client = common::new_test_client(base_url.clone())?; // Perform attestation handshake with real AWS Nitro attestation @@ -57,17 +57,14 @@ async fn test_attestation_handshake_hosted_selected_environment() -> Result<()> .expect("Session ID should be set after successful handshake"); assert!(!session_id.to_string().is_empty()); - println!( - "✅ Hosted {:?} attestation successful against {}", - pcr0_environment, base_url - ); + println!("✅ Hosted attestation successful against {}", base_url); println!(" Session ID: {}", session_id); Ok(()) } #[tokio::test] -async fn test_hosted_development_rejects_default_production_policy() -> Result<()> { +async fn test_hosted_development_rejects_explicit_production_policy() -> Result<()> { let base_url = env::var("VITE_OPEN_SECRET_API_URL") .unwrap_or_else(|_| "http://localhost:3000".to_string()); @@ -75,16 +72,25 @@ async fn test_hosted_development_rejects_default_production_policy() -> Result<( println!("Skipping hosted policy-separation test - running against localhost"); return Ok(()); } - if common::selected_pcr0_environment()? != Pcr0Environment::Development { + let is_development_origin = reqwest::Url::parse(&base_url) + .ok() + .is_some_and(|url| url.origin().ascii_serialization() == "https://enclave.secretgpt.ai"); + if !is_development_origin { println!("Skipping hosted development policy-separation test"); return Ok(()); } - let client = OpenSecretClient::new(base_url)?; - let error = client.perform_attestation_handshake().await.unwrap_err(); - - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - assert!(client.get_session_id()?.is_none()); + let production_config = TrustedReleaseConfig::new( + AttestationEnvironment::Production, + "https://attestations.trymaple.ai/tuf/", + b"{}".to_vec(), + )?; + let error = match OpenSecretClient::new_with_attestation_config(base_url, production_config) { + Ok(_) => panic!("production policy must not be accepted for the development origin"), + Err(error) => error, + }; + + assert!(matches!(error, Error::Configuration(_))); Ok(()) } @@ -93,6 +99,13 @@ async fn test_attestation_nonce_verification() -> Result<()> { let base_url = env::var("VITE_OPEN_SECRET_API_URL") .unwrap_or_else(|_| "http://localhost:3000".to_string()); + if !cfg!(feature = "mock-attestation") + && (base_url.contains("localhost") || base_url.contains("127.0.0.1")) + { + println!("Skipping localhost mock test without mock-attestation feature"); + return Ok(()); + } + let client = common::new_test_client(base_url.clone())?; // The handshake should generate a unique nonce internally diff --git a/sdk/rust/tests/common/mod.rs b/sdk/rust/tests/common/mod.rs index 2928b3707..a37563f80 100644 --- a/sdk/rust/tests/common/mod.rs +++ b/sdk/rust/tests/common/mod.rs @@ -1,50 +1,19 @@ #![allow(dead_code)] -use opensecret::{Error, OpenSecretClient, Pcr0Environment, Result}; -use std::env::{self, VarError}; - -const PCR_ENVIRONMENT_VARIABLE: &str = "VITE_OPEN_SECRET_PCR_ENVIRONMENT"; -const PCR_ENVIRONMENT_ERROR: &str = - "VITE_OPEN_SECRET_PCR_ENVIRONMENT must be either \"production\" or \"development\""; +use opensecret::{OpenSecretClient, Result}; +use std::env; pub fn live_ai_enabled() -> bool { env::var("RUN_LIVE_AI").is_ok_and(|value| value == "1") } -pub fn parse_pcr0_environment( - value: Option<&str>, -) -> std::result::Result { - match value { - None | Some("production") => Ok(Pcr0Environment::Production), - Some("development") => Ok(Pcr0Environment::Development), - Some(_) => Err(PCR_ENVIRONMENT_ERROR), - } -} - -pub fn selected_pcr0_environment() -> Result { - let configured = match env::var(PCR_ENVIRONMENT_VARIABLE) { - Ok(value) => Some(value), - Err(VarError::NotPresent) => None, - Err(VarError::NotUnicode(_)) => { - return Err(Error::Configuration(PCR_ENVIRONMENT_ERROR.to_string())); - } - }; - - parse_pcr0_environment(configured.as_deref()) - .map_err(|message| Error::Configuration(message.to_string())) -} - pub fn new_test_client(base_url: impl Into) -> Result { - OpenSecretClient::new_with_pcr0_environment(base_url, selected_pcr0_environment()?) + OpenSecretClient::new(base_url) } pub fn new_test_client_with_api_key( base_url: impl Into, api_key: String, ) -> Result { - OpenSecretClient::new_with_api_key_and_pcr0_environment( - base_url, - api_key, - selected_pcr0_environment()?, - ) + OpenSecretClient::new_with_api_key(base_url, api_key) } diff --git a/sdk/rust/tests/fixtures/cosign-v3-blob.sigstore.json b/sdk/rust/tests/fixtures/cosign-v3-blob.sigstore.json new file mode 100644 index 000000000..1f53a08de --- /dev/null +++ b/sdk/rust/tests/fixtures/cosign-v3-blob.sigstore.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial":{"certificate":{"rawBytes":"MIIC1TCCAlygAwIBAgIUQ6rCmpLcP7MAAjGQvMmPgMtSJdQwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUxMjAzMTgzNjQyWhcNMjUxMjAzMTg0NjQyWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFodOSridzGjgIAIl3/2N+eP4dMBEM0oMNJnbWLPBnASGSdjtYr8KvEoxYXTqc47tu22hKYyfnNPkADR1Q9FXeKOCAXswggF3MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUJjVZgdkHLo7sM1/lIx5dEthq9mgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wJAYDVR0RAQH/BBowGIEWdy52b2xscHJlY2h0QGdtYWlsLmNvbTAsBgorBgEEAYO/MAEBBB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwLgYKKwYBBAGDvzABCAQgDB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwgYsGCisGAQQB1nkCBAIEfQR7AHkAdwDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZrlgJ0SAAAEAwBIMEYCIQC19RUfoY4zDUcXuEFD+jCs123iUaL3QzSC//Kf67mp5QIhAMtP95BFoDh17zzIVI5Dz2PJJx9KXG5eVdPrpkV2suvAMAoGCCqGSM49BAMDA2cAMGQCMHN5sdKmaUOF2pBGygVP8xlrxTHjh9A1y6B6YkXzpB8WAsjX0vqsPJ8s8gWFjfLo0wIwFPXpDGO0hentwcuKCnl10/Vk1yFUrb6BB0/Fg+1yJiiBn4FsJUbWacpt7KHCqQu1"}, "tlogEntries":[{"logIndex":"738312748", "logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion":{"kind":"hashedrekord", "version":"0.0.1"}, "integratedTime":"1764787003", "inclusionPromise":{"signedEntryTimestamp":"MEUCIGduMyNHwdmNItO2glTI3vsJcX0UZJ7gPl9mb/+9gCYRAiEAvwcs6NeB049MkPeRa/t9tbkBUzVijo+fYxriOiEX994="}, "inclusionProof":{"logIndex":"616408486", "rootHash":"dMhojWLwKZjp3xdLBFgCysbIYp4FTe94vs70zFB4nAM=", "treeSize":"616408488", "hashes":["PXpZ49ay970tZNI9tKk2a8N3w2YdhDeXo4xr4fldbTs=", "1E5+TJDHAuuUwwZBcfvFYKuwJbBirKU17fRl1zH48hg=", "NkvH/aihlVQn4eqiKz2NVaPTPYslEStiaKwdOm/G9zY=", "aQmQQHU/7x9gnmLiB6M2jAGKuw73ALuyUm+wCfZW7bg=", "fcYaHYkZkRlEMpxUKAOD33AH4yBLcLEOBC6L5Lkv6/I=", "+jY3qOWPfdh1hLyOWUgtjykWOAlnyX2KIHU5+ONqVUI=", "toOguUn75si9YDuf9zTmXNv47noi41dcEcdegoMtJgo=", "rQVrtBDyDEqA6tmX1hbd2SHoYdcw1cKBoastRdMC0Lg=", "2lqmP0g7dTwttCuD3m5rAbf8af9Ydl/2Ct3fz7wwCJo=", "P07VUPWy7Jq8+bSaQ5xCZVQhhdVzYywP94WlTMaAifU=", "QRYmzeY4Zi71BnW0Mh1SWT9pavlAn9DhRMiRIgiTX9Q=", "4uYCK3kl03eiD37zCe/PGO3kCP/yLCSWEaTYkLGifio=", "aosWMjs6qwpL80j4koy1dyO/vNa5Q6NlvxMT+2T2GwA=", "Wf0Z+YbBL8mPn9AeMCdcxxuSxc7DuhUVP1iFflyC/hc=", "huaH1ZSkRyP4+vpmGtpmkkL845lhcmN9io8MIe6Sob0=", "ZmUkYkHBy1B723JrEgiKvepTdHYrP6y2a4oODYvi5VY=", "T4DqWD42hAtN+vX8jKCWqoC4meE4JekI9LxYGCcPy1M="], "checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n616408488\ndMhojWLwKZjp3xdLBFgCysbIYp4FTe94vs70zFB4nAM=\n\n— rekor.sigstore.dev wNI9ajBEAiB9yqLZxKPPSG4EYgyJq9C884egwJ32aYU8MyqeYXYiJQIgebrTUhIavn7+VyFhLnWNx7yC+ftNqRNfu+IzwTv08KM=\n"}}, "canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJlMTk0Yjk5ZGY2NjM5NTAxMzI1YzVmNDRkYTc5ZmUzYThlZmFlNmYyMzliZDc5NDQzYTE5Nzg3ZTA2ZmIxNzY0In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJRkx0SmQ2WCtUU3pxOURFM3Z1eGltWkNuSmVZTTM0RnJMa3hVK3dpTE1MSkFpRUFneFQ3VnQzbVh1Tm5jT25vM3lvcFkwUW5vTlpMODE5aVoxT0xhcGNrYUJ3PSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTXhWRU5EUVd4NVowRjNTVUpCWjBsVlVUWnlRMjF3VEdOUU4wMUJRV3BIVVhaTmJWQm5UWFJUU21SUmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFZlRTFxUVhwTlZHZDZUbXBSZVZkb1kwNU5hbFY0VFdwQmVrMVVaekJPYWxGNVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZHYjJSUFUzSnBaSHBIYW1kSlFVbHNNeTh5VGl0bFVEUmtUVUpGVFRCdlRVNUtibUlLVjB4UVFtNUJVMGRUWkdwMFdYSTRTM1pGYjNoWldGUnhZelEzZEhVeU1taExXWGxtYms1UWEwRkVVakZST1VaWVpVdFBRMEZZYzNkblowWXpUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZLYWxaYUNtZGthMGhNYnpkelRURXZiRWw0TldSRmRHaHhPVzFuZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwQldVUldVakJTUVZGSUwwSkNiM2RIU1VWWFpIazFNbUl5ZUhOalNFcHNXVEpvTUZGSFpIUlpWMnh6VEcxT2RtSlVRWE5DWjI5eVFtZEZSUXBCV1U4dlRVRkZRa0pDTlc5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFppUnpsdVlWYzBkbUl5UmpGa1IyZDNUR2RaUzB0M1dVSkNRVWRFQ25aNlFVSkRRVkZuUkVJMWIyUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1KSE9XNWhWelIyWWpKR01XUkhaM2RuV1hOSFEybHpSMEZSVVVJS01XNXJRMEpCU1VWbVVWSTNRVWhyUVdSM1JHUlFWRUp4ZUhOalVrMXRUVnBJYUhsYVducGpRMjlyY0dWMVRqUTRjbVlyU0dsdVMwRk1lVzUxYW1kQlFRcEJXbkpzWjBvd1UwRkJRVVZCZDBKSlRVVlpRMGxSUXpFNVVsVm1iMWswZWtSVlkxaDFSVVpFSzJwRGN6RXlNMmxWWVV3elVYcFRReTh2UzJZMk4yMXdDalZSU1doQlRYUlFPVFZDUm05RWFERTNlbnBKVmtrMVJIb3lVRXBLZURsTFdFYzFaVlprVUhKd2ExWXljM1YyUVUxQmIwZERRM0ZIVTAwME9VSkJUVVFLUVRKalFVMUhVVU5OU0U0MWMyUkxiV0ZWVDBZeWNFSkhlV2RXVURoNGJISjRWRWhxYURsQk1YazJRalpaYTFoNmNFSTRWMEZ6YWxnd2RuRnpVRW80Y3dvNFoxZEdhbVpNYnpCM1NYZEdVRmh3UkVkUE1HaGxiblIzWTNWTFEyNXNNVEF2Vm1zeGVVWlZjbUkyUWtJd0wwWm5LekY1U21scFFtNDBSbk5LVldKWENtRmpjSFEzUzBoRGNWRjFNUW90TFMwdExVVk9SQ0JEUlZKVVNVWkpRMEZVUlMwdExTMHRDZz09In19fX0="}], "timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQguqhf7Onr5+rrhPgQSAQ2M98wRN4SunAlt3DRfOdeem4CFEvA/X2Y+Q7SgSeIzV1sd9Lg1hLkGA8yMDI1MTIwMzE4MzY0MlowAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdswggHXAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjUxMjAzMTgzNjQyWjAvBgkqhkiG9w0BCQQxIgQgP6xU4az/FUNWOr+jjTj3KXhOKoNUQG3ZGnpZcUvMg/YwgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGcwZQIxANQPSum/tL0sOFNuMzbhrzdfPxu5/cQ5s0KoILeeaygvJS6HLFxL1/vI7FpIXZUO3QIwRLuZtNOwyQ8Dr8YWhaqred+vhJXCADGEEExEkiHX1kYEYkPZOvk/j85M2aBrYYHA"}]}}, "messageSignature":{"messageDigest":{"algorithm":"SHA2_256", "digest":"4ZS5nfZjlQEyXF9E2nn+Oo765vI5vXlEOhl4fgb7F2Q="}, "signature":"MEUCIFLtJd6X+TSzq9DE3vuximZCnJeYM34FrLkxU+wiLMLJAiEAgxT7Vt3mXuNncOno3yopY0QnoNZL819iZ1OLapckaBw="}} diff --git a/sdk/rust/tests/fixtures/rekor-v2-artifact.txt b/sdk/rust/tests/fixtures/rekor-v2-artifact.txt new file mode 100644 index 000000000..52c1fa7a1 --- /dev/null +++ b/sdk/rust/tests/fixtures/rekor-v2-artifact.txt @@ -0,0 +1,5 @@ +DO NOT MODIFY ME! + +this is "a.txt", a sample input for sigstore-conformance's test suite. + +DO NOT MODIFY ME! diff --git a/sdk/rust/tests/fixtures/rekor-v2-bundle.sigstore.fixture b/sdk/rust/tests/fixtures/rekor-v2-bundle.sigstore.fixture new file mode 100644 index 000000000..b58393ba0 --- /dev/null +++ b/sdk/rust/tests/fixtures/rekor-v2-bundle.sigstore.fixture @@ -0,0 +1 @@ +{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial": {"certificate": {"rawBytes": "MIIIMTCCB7egAwIBAgIUJGo5qgyKJj/TX1P23VhIcFW0gi4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUwNjEyMTIwMjE2WhcNMjUwNjEyMTIxMjE2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9WKdUhMKYRNGrsqtBl4wwhbuz+RNC1muGGSzMvNHwriIWC55x1KX3DiRADCKt38HhBY1CLjnB1Hc3qxtZCrDX6OCBtYwggbSMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQU/2fXeMOxe3bGQ691Q+dusSVoh90wHwYDVR0jBBgwFoAUcYYwphR8Ym/599b0BRp/X//rb6wwgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBCg5NzM5Nzk4OTQ1YjJhODQ4ZTkxMWJjZjFkZGRiOTcyOThjYWRlMGIxMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDCg5NzM5Nzk4OTQ1YjJhODQ4ZTkxMWJjZjFkZGRiOTcyOThjYWRlMGIxMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKDk3Mzk3OTg5NDViMmE4NDhlOTExYmNmMWRkZGI5NzI5OGNhZGUwYjEwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoOTczOTc5ODk0NWIyYTg0OGU5MTFiY2YxZGRkYjk3Mjk4Y2FkZTBiMTAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGCBgorBgEEAYO/MAEVBHQMcmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvMTU2MDk4NzcwODYvYXR0ZW1wdHMvMTAWBgorBgEEAYO/MAEWBAgMBnB1YmxpYzCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1ACswvNxoiMni4dgmKV50H0g5MZYC8pwzy15DQP6yrIZ6AAABl2QE9+oAAAQDAEYwRAIgcMDyOAyLXLPjZqFcfQgMvctTQo8lhUXYyU1cQDfWyy0CIB9ybqPHF1jY9pbZMHYUJd2gPtapR90L/RYQIo2SdI7NMAoGCCqGSM49BAMDA2gAMGUCMASvG7iyBIiTLy+l5YjKtSs43sNA0lg7JM/oJXwk/20B26P5Yl6SBKOHypGeoM28EAIxAOkdwVfpMuDU32aAf61O4CA1c4Sr1CjCuRmfQ3NfcxYzNTPmtTGwZuIDjKj29ZRBWA=="}, "tlogEntries": [{"logIndex": "735", "logId": {"keyId": "8w1amZ2S5mJIQkQmPxdMuOrL/oJkvFg9MnQXmeOCXck="}, "kindVersion": {"kind": "hashedrekord", "version": "0.0.2"}, "inclusionProof": {"logIndex": "735", "rootHash": "rs1YPY0ydAV0lxgfrq5pE4oRpUJwo3syeps5+eGUTDI=", "treeSize": "736", "hashes": ["JW27adKabAL7le2rFDSEUhPM94lzNjlhqi1BDFCFLCQ=", "RbML4EU6v7vmDTLhcSzoi9tXr2IpqvGdXSofxij89RA=", "W04Xfh+3qi0jWpYoDkt7GOrs5rRcDkZ2DH4P7YRmeVo=", "cuFXxcpaflWsMid8mdJIBbBm4X25GQzOS87ZCrU0zgY=", "Q1YXRmGYBNGsajHNJEPEJJUBUCoG4RbZx2bpvuDUxBk=", "+gnK+M5cyTZ0UncCImJch9APOM+yjuVvfEuX7z6AamQ=", "QMesRTEZdIgthOEinYE/9J7wGv+VmArDZTICj9POmhY=", "UNUMG62rMwoqCqFKknh4R5Ubkf5Z6dj+Pk0m/1xu8uo="], "checkpoint": {"envelope": "log2025-alpha1.rekor.sigstage.dev\n736\nrs1YPY0ydAV0lxgfrq5pE4oRpUJwo3syeps5+eGUTDI=\n\n\u2014 log2025-alpha1.rekor.sigstage.dev 8w1amdbj1mjNN674dHAkD92+QZoEgBC7o0mXYSTRluDjQrOPjrps3zQB9ut+ShLepyZPsWBDi5IB3yXyjgjQT6OG9A8=\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjIiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJoYXNoZWRSZWtvcmRWMDAyIjp7ImRhdGEiOnsiYWxnb3JpdGhtIjoiU0hBMl8yNTYiLCJkaWdlc3QiOiJvTS9IRW5IVzRuamxmTk15LzVWOFAzQkQvZG8xVEV5N0dRb3cxVzc2QWI4PSJ9LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUURNMFl4Sm43VnNVYzdGdlU3U0JYZWxTekZVV2V3YWVRbGJoUDVtYnBWbmF3SWhBUGFaNWRuczFhMzhLZ2VsV1dDczFTVzFseXJYeFZ6MlJ0aWlGS0RPRUxsQyIsInZlcmlmaWVyIjp7ImtleURldGFpbHMiOiJQS0lYX0VDRFNBX1AyNTZfU0hBXzI1NiIsIng1MDlDZXJ0aWZpY2F0ZSI6eyJyYXdCeXRlcyI6Ik1JSUlNVENDQjdlZ0F3SUJBZ0lVSkdvNXFneUtKai9UWDFQMjNWaEljRlcwZ2k0d0NnWUlLb1pJemowRUF3TXdOekVWTUJNR0ExVUVDaE1NYzJsbmMzUnZjbVV1WkdWMk1SNHdIQVlEVlFRREV4VnphV2R6ZEc5eVpTMXBiblJsY20xbFpHbGhkR1V3SGhjTk1qVXdOakV5TVRJd01qRTJXaGNOTWpVd05qRXlNVEl4TWpFMldqQUFNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUU5V0tkVWhNS1lSTkdyc3F0Qmw0d3doYnV6K1JOQzFtdUdHU3pNdk5Id3JpSVdDNTV4MUtYM0RpUkFEQ0t0MzhIaEJZMUNMam5CMUhjM3F4dFpDckRYNk9DQnRZd2dnYlNNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBekFkQmdOVkhRNEVGZ1FVLzJmWGVNT3hlM2JHUTY5MVErZHVzU1ZvaDkwd0h3WURWUjBqQkJnd0ZvQVVjWVl3cGhSOFltLzU5OWIwQlJwL1gvL3JiNnd3Z2FVR0ExVWRFUUVCL3dTQm1qQ0JsNGFCbEdoMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5emFXZHpkRzl5WlMxamIyNW1iM0p0WVc1alpTOWxlSFJ5WlcxbGJIa3RaR0Z1WjJWeWIzVnpMWEIxWW14cFl5MXZhV1JqTFdKbFlXTnZiaTh1WjJsMGFIVmlMM2R2Y210bWJHOTNjeTlsZUhSeVpXMWxiSGt0WkdGdVoyVnliM1Z6TFc5cFpHTXRZbVZoWTI5dUxubHRiRUJ5WldaekwyaGxZV1J6TDIxaGFXNHdPUVlLS3dZQkJBR0R2ekFCQVFRcmFIUjBjSE02THk5MGIydGxiaTVoWTNScGIyNXpMbWRwZEdoMVluVnpaWEpqYjI1MFpXNTBMbU52YlRBZkJnb3JCZ0VFQVlPL01BRUNCQkYzYjNKclpteHZkMTlrYVhOd1lYUmphREEyQmdvckJnRUVBWU8vTUFFREJDZzVOek01TnprNE9UUTFZakpoT0RRNFpUa3hNV0pqWmpGa1pHUmlPVGN5T1RoallXUmxNR0l4TUMwR0Npc0dBUVFCZzc4d0FRUUVIMFY0ZEhKbGJXVnNlU0JrWVc1blpYSnZkWE1nVDBsRVF5QmlaV0ZqYjI0d1NRWUtLd1lCQkFHRHZ6QUJCUVE3YzJsbmMzUnZjbVV0WTI5dVptOXliV0Z1WTJVdlpYaDBjbVZ0Wld4NUxXUmhibWRsY205MWN5MXdkV0pzYVdNdGIybGtZeTFpWldGamIyNHdIUVlLS3dZQkJBR0R2ekFCQmdRUGNtVm1jeTlvWldGa2N5OXRZV2x1TURzR0Npc0dBUVFCZzc4d0FRZ0VMUXdyYUhSMGNITTZMeTkwYjJ0bGJpNWhZM1JwYjI1ekxtZHBkR2gxWW5WelpYSmpiMjUwWlc1MExtTnZiVENCcGdZS0t3WUJCQUdEdnpBQkNRU0Jsd3lCbEdoMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5emFXZHpkRzl5WlMxamIyNW1iM0p0WVc1alpTOWxlSFJ5WlcxbGJIa3RaR0Z1WjJWeWIzVnpMWEIxWW14cFl5MXZhV1JqTFdKbFlXTnZiaTh1WjJsMGFIVmlMM2R2Y210bWJHOTNjeTlsZUhSeVpXMWxiSGt0WkdGdVoyVnliM1Z6TFc5cFpHTXRZbVZoWTI5dUxubHRiRUJ5WldaekwyaGxZV1J6TDIxaGFXNHdPQVlLS3dZQkJBR0R2ekFCQ2dRcURDZzVOek01TnprNE9UUTFZakpoT0RRNFpUa3hNV0pqWmpGa1pHUmlPVGN5T1RoallXUmxNR0l4TUIwR0Npc0dBUVFCZzc4d0FRc0VEd3dOWjJsMGFIVmlMV2h2YzNSbFpEQmVCZ29yQmdFRUFZTy9NQUVNQkZBTVRtaDBkSEJ6T2k4dloybDBhSFZpTG1OdmJTOXphV2R6ZEc5eVpTMWpiMjVtYjNKdFlXNWpaUzlsZUhSeVpXMWxiSGt0WkdGdVoyVnliM1Z6TFhCMVlteHBZeTF2YVdSakxXSmxZV052YmpBNEJnb3JCZ0VFQVlPL01BRU5CQ29NS0RrM016azNPVGc1TkRWaU1tRTRORGhsT1RFeFltTm1NV1JrWkdJNU56STVPR05oWkdVd1lqRXdId1lLS3dZQkJBR0R2ekFCRGdRUkRBOXlaV1p6TDJobFlXUnpMMjFoYVc0d0dRWUtLd1lCQkFHRHZ6QUJEd1FMREFrMk16STFPVFk0T1Rjd053WUtLd1lCQkFHRHZ6QUJFQVFwRENkb2RIUndjem92TDJkcGRHaDFZaTVqYjIwdmMybG5jM1J2Y21VdFkyOXVabTl5YldGdVkyVXdHUVlLS3dZQkJBR0R2ekFCRVFRTERBa3hNekU0TURRMU5qTXdnYVlHQ2lzR0FRUUJnNzh3QVJJRWdaY01nWlJvZEhSd2N6b3ZMMmRwZEdoMVlpNWpiMjB2YzJsbmMzUnZjbVV0WTI5dVptOXliV0Z1WTJVdlpYaDBjbVZ0Wld4NUxXUmhibWRsY205MWN5MXdkV0pzYVdNdGIybGtZeTFpWldGamIyNHZMbWRwZEdoMVlpOTNiM0pyWm14dmQzTXZaWGgwY21WdFpXeDVMV1JoYm1kbGNtOTFjeTF2YVdSakxXSmxZV052Ymk1NWJXeEFjbVZtY3k5b1pXRmtjeTl0WVdsdU1EZ0dDaXNHQVFRQmc3OHdBUk1FS2d3b09UY3pPVGM1T0RrME5XSXlZVGcwT0dVNU1URmlZMll4WkdSa1lqazNNams0WTJGa1pUQmlNVEFoQmdvckJnRUVBWU8vTUFFVUJCTU1FWGR2Y210bWJHOTNYMlJwYzNCaGRHTm9NSUdDQmdvckJnRUVBWU8vTUFFVkJIUU1jbWgwZEhCek9pOHZaMmwwYUhWaUxtTnZiUzl6YVdkemRHOXlaUzFqYjI1bWIzSnRZVzVqWlM5bGVIUnlaVzFsYkhrdFpHRnVaMlZ5YjNWekxYQjFZbXhwWXkxdmFXUmpMV0psWVdOdmJpOWhZM1JwYjI1ekwzSjFibk12TVRVMk1EazROemN3T0RZdllYUjBaVzF3ZEhNdk1UQVdCZ29yQmdFRUFZTy9NQUVXQkFnTUJuQjFZbXhwWXpDQmlRWUtLd1lCQkFIV2VRSUVBZ1I3QkhrQWR3QjFBQ3N3dk54b2lNbmk0ZGdtS1Y1MEgwZzVNWllDOHB3enkxNURRUDZ5cklaNkFBQUJsMlFFOStvQUFBUURBRVl3UkFJZ2NNRHlPQXlMWExQalpxRmNmUWdNdmN0VFFvOGxoVVhZeVUxY1FEZld5eTBDSUI5eWJxUEhGMWpZOXBiWk1IWVVKZDJnUHRhcFI5MEwvUllRSW8yU2RJN05NQW9HQ0NxR1NNNDlCQU1EQTJnQU1HVUNNQVN2RzdpeUJJaVRMeStsNVlqS3RTczQzc05BMGxnN0pNL29KWHdrLzIwQjI2UDVZbDZTQktPSHlwR2VvTTI4RUFJeEFPa2R3VmZwTXVEVTMyYUFmNjFPNENBMWM0U3IxQ2pDdVJtZlEzTmZjeFl6TlRQbXRUR3dadUlEaktqMjlaUkJXQT09In19fX19fQ=="}], "timestampVerificationData": {"rfc3161Timestamps": [{"signedTimestamp": "MIIE6jADAgEAMIIE4QYJKoZIhvcNAQcCoIIE0jCCBM4CAQMxDTALBglghkgBZQMEAgEwgcIGCyqGSIb3DQEJEAEEoIGyBIGvMIGsAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgbd8xYJ4qbIFPgmaqt1IEt6H16W0bwH3eb/Oa3YQIzaICFFWXQsce2uW2RSM6esQ6jQMxQqWsGA8yMDI1MDYxMjEyMDIyMFowAwIBAQIJAI9Z8q/mnMiooDKkMDAuMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxFTATBgNVBAMTDHNpZ3N0b3JlLXRzYaCCAhMwggIPMIIBlqADAgECAhQKNaEGYdXiQXPGiZan8n3yfgN8pzAKBggqhkjOPQQDAzA5MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxIDAeBgNVBAMTF3NpZ3N0b3JlLXRzYS1zZWxmc2lnbmVkMB4XDTI1MDMyODA5MTQwNloXDTM1MDMyNjA4MTQwNlowLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATHW/kXcekP16Ae6SekEWVHPtAFEMm7hp5XO33MktFjSW+bHWUXtYEzZz0A3xkY9CyYOoeUk3ZH/v5HEuS+UvORzX0g7Hfy3uYYYRwHtqBQN0IX8rLdFMtIrRej/QCAdB2jajBoMA4GA1UdDwEB/wQEAwIHgDAdBgNVHQ4EFgQUqPxk9ijeLuY7c09UjFLE4ZzdU6UwHwYDVR0jBBgwFoAUOyBGWV61Mk1HMM5uY+5zdEfyBH0wFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwCgYIKoZIzj0EAwMDZwAwZAIwRK9VLoYa0Xff4nX1N/AQ1YleNG/iLT8dAXAtRKRfpN9XuDScbxWeo0cku8SkC06NAjBQPe7LBNeitA/UOBtXT2sX1h6f4ISqz+ISmJ4lY+y3bzRJI5nk1r53I9WT3/xIWToxggHcMIIB2AIBATBRMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFAo1oQZh1eJBc8aJlqfyffJ+A3ynMAsGCWCGSAFlAwQCAaCB/DAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8XDTI1MDYxMjEyMDIyMFowLwYJKoZIhvcNAQkEMSIEICXnz5HAQnH9WOuMmb4hZGrrZvGZPI98Lw0T3ZgcdddfMIGOBgsqhkiG9w0BCRACLzF/MH0wezB5BCAG9P/gR/6zWZm3M7DXoyNQHPwY5MAzZqhF13U250snRDBVMD2kOzA5MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxIDAeBgNVBAMTF3NpZ3N0b3JlLXRzYS1zZWxmc2lnbmVkAhQKNaEGYdXiQXPGiZan8n3yfgN8pzAKBggqhkjOPQQDAgRoMGYCMQCH3FfjUxAybs2PGGGimOaahUA2wDuwYws22nzLnsGEwGg6b6GOuMduqGpbSuuJnoQCMQDhzG6dCCyjgB+O9++D89S2ShJeUeJ0QSMwYiAelDXX5nMg0m6dxLVJCB5HSyku32g="}]}}, "messageSignature": {"messageDigest": {"algorithm": "SHA2_256", "digest": "oM/HEnHW4njlfNMy/5V8P3BD/do1TEy7GQow1W76Ab8="}, "signature": "MEYCIQDM0YxJn7VsUc7FvU7SBXelSzFUWewaeQlbhP5mbpVnawIhAPaZ5dns1a38KgelWWCs1SW1lyrXxVz2RtiiFKDOELlC"}} diff --git a/sdk/rust/tests/fixtures/rekor-v2-trusted-root.fixture b/sdk/rust/tests/fixtures/rekor-v2-trusted-root.fixture new file mode 100644 index 000000000..d565b63e9 --- /dev/null +++ b/sdk/rust/tests/fixtures/rekor-v2-trusted-root.fixture @@ -0,0 +1,123 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstage.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDODRU688UYGuy54mNUlaEBiQdTE9nYLr0lg6RXowI/QV/RE1azBn4Eg5/2uTOMbhB1/gfcHzijzFi9Tk+g1Prg==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY=" + } + }, + { + "baseUrl": "https://log2025-alpha1.rekor.sigstage.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAPn+AREHoBaZ7wgS1zBqpxmLSGnyhxXj4lFxSdWVB8o8=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-04-16T00:00:00Z" + } + }, + "logId": { + "keyId": "8w1amZ2S5mJIQkQmPxdMuOrL/oJkvFg9MnQXmeOCXck=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstage.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGTCCAaCgAwIBAgITJta/okfgHvjabGm1BOzuhrwA1TAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIyMDQxNDIxMzg0MFoXDTMyMDMyMjE2NTA0NVowNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASosAySWJQ/tK5r8T5aHqavk0oI+BKQbnLLdmOMRXHQF/4Hx9KtNfpcdjH9hNKQSBxSlLFFN3tvFCco0qFBzWYwZtsYsBe1l91qYn/9VHFTaEVwYQWIJEEvrs0fvPuAqjajezB5MA4GA1UdDwEB/wQEAwIBBjATBgNVHSUEDDAKBggrBgEFBQcDAzASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRxhjCmFHxib/n31vQFGn9f/+tvrDAfBgNVHSMEGDAWgBT/QjK6aH2rOnCv3AzUGuI+h49mZTAKBggqhkjOPQQDAwNnADBkAjAM1lbKkcqQlE/UspMTbWNo1y2TaJ44tx3l/FJFceTSdDZ+0W1OHHeU4twie/lq8XgCMHQxgEv26xNNiAGyPXbkYgrDPvbOqp0UeWX4mJnLSrBr3aN/KX1SBrKQu220FmVL0Q==" + }, + { + "rawBytes": "MIIB9jCCAXugAwIBAgITDdEJvluliE0AzYaIE4jTMdnFTzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIyMDMyNTE2NTA0NloXDTMyMDMyMjE2NTA0NVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABMo9BUNk9QIYisYysC24+2OytoV72YiLonYcqR3yeVnYziPt7Xv++CYE8yoCTiwedUECCWKOcvQKRCJZb9ht4Hzy+VvBx36hK+C6sECCSR0x6pPSiz+cTk1f788ZjBlUZaNjMGEwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP9CMrpofas6cK/cDNQa4j6Hj2ZlMB8GA1UdIwQYMBaAFP9CMrpofas6cK/cDNQa4j6Hj2ZlMAoGCCqGSM49BAMDA2kAMGYCMQD+kojuzMwztNay9Ibzjuk//ZL5m6T2OCsm45l1lY004pcb984L926BowodoirFMcMCMQDIJtFHhP/1D3a+M3dAGomOb6O4CmTry3TTPbPsAFnv22YA0Y+P21NVoxKDjdu0tkw=" + } + ] + }, + "validFor": { + "start": "2022-04-14T21:38:40Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstage.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MIICCgKCAgEA27A2MPQXm0I0v7/Ly5BIauDjRZF5Jor9vU+QheoE2UIIsZHcyYq3slHzSSHy2lLj1ZD2d91CtJ492ZXqnBmsr4TwZ9jQ05tW2mGIRI8u2DqN8LpuNYZGz/f9SZrjhQQmUttqWmtu3UoLfKz6NbNXUnoo+NhZFcFRLXJ8VporVhuiAmL7zqT53cXR3yQfFPCUDeGnRksnlhVIAJc3AHZZSHQJ8DEXMhh35TVv2nYhTI3rID7GwjXXw4ocz7RGDD37ky6p39Tl5NB71gT1eSqhZhGHEYHIPXraEBd5+3w9qIuLWlp5Ej/K6Mu4ELioXKCUimCbwy+Cs8UhHFlqcyg4AysOHJwIadXIa8LsY51jnVSGrGOEBZevopmQPNPtyfFY3dmXSS+6Z3RD2Gd6oDnNGJzpSyEk410Ag5uvNDfYzJLCWX9tU8lIxNwdFYmIwpd89HijyRyoGnoJ3entd63cvKfuuix5r+GHyKp1Xm1L5j5AWM6P+z0xigwkiXnt+adexAl1J9wdDxv/pUFEESRF4DG8DFGVtbdH6aR1A5/vD4krO4tC1QYUSeyL5Mvsw8WRqIFHcXtgybtxylljvNcGMV1KXQC8UFDmpGZVDSHx6v3e/BHMrZ7gjoCCfVMZ/cFcQi0W2AIHPYEMH/C95J2r4XbHMRdYXpovpOoT5Ca78gsCAwEAAQ==", + "keyDetails": "PKCS1_RSA_PKCS1V5", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-07-31T00:00:00Z" + } + }, + "logId": { + "keyId": "G3wUKk6ZK6ffHh/FdCRUE2wVekyzHEEIpSG4savnv0w=" + } + }, + { + "baseUrl": "https://ctfe.sigstage.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEh99xuRi6slBFd8VUJoK/rLigy4bYeSYWO/fE6Br7r0D8NpMI94+A63LR/WvLxpUUGBpY8IJA3iU2telag5CRpA==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-07-01T00:00:00Z", + "end": "2022-07-31T00:00:00Z" + } + }, + "logId": { + "keyId": "++JKOMQt7SJ3ynUHnCfnDhcKP8/58J4TueMqXuk3HmA=" + } + }, + { + "baseUrl": "https://ctfe.sigstage.dev/2022-2", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8gEDKNme8AnXuPBgHjrtXdS6miHqc24CRblNEOFpiJRngeq8Ko73Y+K18yRYVf1DXD4AVLwvKyzdNdl5n0jUSQ==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-07-01T00:00:00Z" + } + }, + "logId": { + "keyId": "KzC83GiIyeLh2CYpXnQfSDkxlgLynDPLXkNA/rKshno=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstage.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICDzCCAZagAwIBAgIUCjWhBmHV4kFzxomWp/J98n4DfKcwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTAzMjgwOTE0MDZaFw0zNTAzMjYwODE0MDZaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEx1v5F3HpD9egHuknpBFlRz7QBRDJu4aeVzt9zJLRY0lvmx1lF7WBM2c9AN8ZGPQsmDqHlJN2R/7+RxLkvlLzkc19IOx38t7mGGEcB7agUDdCF/Ky3RTLSK0Xo/0AgHQdo2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFKj8ZPYo3i7mO3NPVIxSxOGc3VOlMB8GA1UdIwQYMBaAFDsgRlletTJNRzDObmPuc3RH8gR9MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2cAMGQCMESvVS6GGtF33+J19TfwENWJXjRv4i0/HQFwLUSkX6TfV7g0nG8VnqNHJLvEpAtOjQIwUD3uywTXorQP1DgbV09rF9Yen+CEqs/iEpieJWPst280SSOZ5Na+dyPVk9/8SFk6" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUCPExEFKiQh0dP4sp5ltmSYSSkFUwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTAzMjgwOTE0MDZaFw0zNTAzMjYwODE0MDZaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATt0tIDWyo4ARfL9BaSo0W5bJQEbKJTU/u7llvdjSI5aTkOAJa8tixn2+LEfPG4dMFdsMPtsIuU1qn2OqFiuMk6vHv/c+az25RQVY1oo50iMb0jIL3N4FgwhPFpZnCbQPOjRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ7IEZZXrUyTUcwzm5j7nN0R/IEfTAKBggqhkjOPQQDAwNpADBmAjEA2MI1VXgbf3dUOSc95hSRypBKOab18eh2xzQtxUsHvWeY+1iFgyMluUuNR6taoSmFAjEA31m2czguZhKYX+4JSKu5pRYhBTXAd8KKQ3xdPRX/qCaLvT2qJAEQ1YQM3EJRrtI7" + } + ] + }, + "validFor": { + "start": "2025-04-09T00:00:00Z" + } + } + ] +} diff --git a/sdk/rust/tests/pcr_environment.rs b/sdk/rust/tests/pcr_environment.rs deleted file mode 100644 index 8bf30436b..000000000 --- a/sdk/rust/tests/pcr_environment.rs +++ /dev/null @@ -1,31 +0,0 @@ -mod common; - -use common::parse_pcr0_environment; -use opensecret::Pcr0Environment; - -#[test] -fn pcr_environment_defaults_to_production() { - assert_eq!( - parse_pcr0_environment(None).unwrap(), - Pcr0Environment::Production - ); -} - -#[test] -fn pcr_environment_accepts_exact_supported_values() { - assert_eq!( - parse_pcr0_environment(Some("production")).unwrap(), - Pcr0Environment::Production - ); - assert_eq!( - parse_pcr0_environment(Some("development")).unwrap(), - Pcr0Environment::Development - ); -} - -#[test] -fn pcr_environment_rejects_empty_differently_cased_and_unknown_values() { - for value in ["", "Production", "dev", " development "] { - assert!(parse_pcr0_environment(Some(value)).is_err()); - } -} diff --git a/sdk/src/lib/attestation-tuf-root.generated.json b/sdk/src/lib/attestation-tuf-root.generated.json new file mode 100644 index 000000000..8b2f75f30 --- /dev/null +++ b/sdk/src/lib/attestation-tuf-root.generated.json @@ -0,0 +1,5 @@ +{ + "schema": "https://attestations.trymaple.ai/schemas/unpublished-tuf-root/v1", + "status": "unpublished", + "message": "Generated production TUF root metadata has not been published yet. Release builds must replace this file." +} diff --git a/sdk/src/lib/attestation.ts b/sdk/src/lib/attestation.ts index f6160e940..f707e2c48 100644 --- a/sdk/src/lib/attestation.ts +++ b/sdk/src/lib/attestation.ts @@ -3,6 +3,13 @@ import { decode, encode } from "@stablelib/base64"; import * as cbor from "cbor2"; import { z } from "zod"; import { fetchAttestationDocument, getApiUrl } from "./api"; +import { + requireTrustedPcrsAgainstSnapshot, + resolveAttestationEnvironment, + resolveTrustedPcrPolicy, + type AttestationEnvironment, + type TrustedEnclaveReleaseSnapshot +} from "./pcr"; import awsRootCertDer from "../assets/aws_root.der"; // Assert that the root cert is not empty @@ -246,7 +253,7 @@ const FakeAttestationDocumentSchema = z.object({ type FakeAttestationDocument = z.infer; -const LOCAL_DEVELOPMENT_API_HOSTS = new Set(["127.0.0.1", "localhost", "0.0.0.0", "[::1]"]); +const LOCAL_DEVELOPMENT_API_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); export function isLocalDevelopmentApiUrl(apiUrl: string): boolean { try { @@ -268,9 +275,11 @@ async function fakeAuthenticate( return zodParsed; } -export async function verifyAttestation( +/** @internal Verify the nonce-bound Nitro document without loading release policy. */ +export async function verifyAttestationDocument( nonce: string, - explicitApiUrl?: string + explicitApiUrl?: string, + expectedEnvironment?: AttestationEnvironment ): Promise { try { const attestationDocumentBase64 = await fetchAttestationDocument(nonce, explicitApiUrl); @@ -288,6 +297,13 @@ export async function verifyAttestation( // The real thing! const verifiedDocument = await authenticate(attestationDocumentBase64, awsRootCertDer, nonce); + if (verifiedDocument.digest !== "SHA384") { + throw new Error("Attestation document must use the SHA384 PCR digest algorithm."); + } + if (!apiUrl) { + throw new Error("Attestation API URL is not configured."); + } + resolveAttestationEnvironment(apiUrl, expectedEnvironment); return verifiedDocument; } catch (error) { if (error instanceof Error) { @@ -299,3 +315,45 @@ export async function verifyAttestation( } } } + +/** + * Verify the Nitro document and authorize its complete PCR tuple with current + * TUF policy. Session establishment uses verifyAttestationDocument internally + * so this policy refresh happens exactly once before key exchange. + */ +export async function verifyAttestation( + nonce: string, + explicitApiUrl?: string, + expectedEnvironment?: AttestationEnvironment +): Promise { + return await verifyAttestationWithDependencies(nonce, explicitApiUrl, expectedEnvironment, { + verifyDocument: verifyAttestationDocument, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot + }); +} + +/** @internal Exported for deterministic ordering tests, not from the package entry point. */ +export async function verifyAttestationWithDependencies( + nonce: string, + explicitApiUrl: string | undefined, + expectedEnvironment: AttestationEnvironment | undefined, + dependencies: { + verifyDocument: typeof verifyAttestationDocument; + resolveTrustedPcrPolicy: typeof resolveTrustedPcrPolicy; + requireTrustedPcrsAgainstSnapshot: typeof requireTrustedPcrsAgainstSnapshot; + } +): Promise { + const apiUrl = explicitApiUrl || getApiUrl(); + let environment: AttestationEnvironment | undefined; + let policy: TrustedEnclaveReleaseSnapshot | undefined; + if (apiUrl && !isLocalDevelopmentApiUrl(apiUrl)) { + environment = resolveAttestationEnvironment(apiUrl, expectedEnvironment); + policy = await dependencies.resolveTrustedPcrPolicy(environment); + } + const document = await dependencies.verifyDocument(nonce, explicitApiUrl, expectedEnvironment); + if (environment && policy) { + await dependencies.requireTrustedPcrsAgainstSnapshot(document.pcrs, environment, policy); + } + return document; +} diff --git a/sdk/src/lib/attestationForView.ts b/sdk/src/lib/attestationForView.ts index d30567d83..fb0c10411 100644 --- a/sdk/src/lib/attestationForView.ts +++ b/sdk/src/lib/attestationForView.ts @@ -2,7 +2,13 @@ import { encode } from "@stablelib/base64"; import { type AttestationDocument } from "./attestation"; import awsRootCertDer from "../assets/aws_root.der"; import { X509Certificate } from "@peculiar/x509"; -import { validatePcr0Hash, type Pcr0ValidationResult, type PcrConfig } from "./pcr"; +import { + validatePcrsAgainstSnapshot, + getTrustedReleaseSnapshotId, + type Pcr0ValidationResult, + type PcrConfig +} from "./pcr"; +import { refreshAttestationPolicy } from "./attestationTuf"; export const AWS_ROOT_CERT_DER = awsRootCertDer; @@ -28,6 +34,9 @@ export type ParsedAttestationView = { userData: string | null; nonce: string | null; cert0hash: string; + /** Full PCR0/PCR1/PCR2 trusted-release validation and Sigstore provenance. */ + validatedPcrs: Pcr0ValidationResult; + /** @deprecated Use validatedPcrs. */ validatedPcr0Hash: Pcr0ValidationResult | null; }; @@ -59,14 +68,17 @@ export async function parseAttestationForView( })) .filter((pcr) => !pcr.value.match(/^0+$/)); - // Find PCR0 and validate it - const pcr0 = pcrs.find((pcr) => pcr.id === 0); - - // Use the single entry point for PCR validation (async) - let validatedPcr0Hash: Pcr0ValidationResult | null = null; - if (pcr0) { - validatedPcr0Hash = await validatePcr0Hash(pcr0.value, pcrConfig); - } + const validatedPcrs = pcrConfig?.environment + ? validatePcrsAgainstSnapshot( + document.pcrs, + pcrConfig.environment, + await refreshAttestationPolicy(pcrConfig.environment) + ) + : { + isMatch: false, + text: "An attestation environment is required for full PCR0/PCR1/PCR2 verification", + snapshotId: getTrustedReleaseSnapshotId() + }; // Parse certificates - cabundle first, then leaf certificate const certificates = [...cabundle, document.certificate].map((certBytes) => { @@ -105,6 +117,7 @@ export async function parseAttestationForView( userData: document.user_data ? decoder.decode(document.user_data) : null, nonce: document.nonce ? decoder.decode(document.nonce) : null, cert0hash, - validatedPcr0Hash + validatedPcrs, + validatedPcr0Hash: validatedPcrs }; } diff --git a/sdk/src/lib/attestationSigstore.ts b/sdk/src/lib/attestationSigstore.ts new file mode 100644 index 000000000..e8c8143ac --- /dev/null +++ b/sdk/src/lib/attestationSigstore.ts @@ -0,0 +1,4 @@ +// The release-policy client always imports the production verifier through this +// fixed binding. Bun tests replace this module at the loader boundary, so no +// verifier replacement seam is compiled into the shipped SDK. +export { verifyTufAuthorizedSigstoreBundle } from "./sigstoreBrowser"; diff --git a/sdk/src/lib/attestationTuf.ts b/sdk/src/lib/attestationTuf.ts new file mode 100644 index 000000000..8fe9d47c4 --- /dev/null +++ b/sdk/src/lib/attestationTuf.ts @@ -0,0 +1,3572 @@ +import { decode as decodeBase64, encode as encodeBase64 } from "@stablelib/base64"; +import nacl from "tweetnacl"; +import { z } from "zod"; +import embeddedBootstrapJson from "./attestation-tuf-root.generated.json"; +import { verifyTufAuthorizedSigstoreBundle } from "./attestationSigstore"; + +export const ATTESTATION_TUF_BASE_URL = "https://attestations.trymaple.ai/tuf"; +const METADATA_BASE_URL = `${ATTESTATION_TUF_BASE_URL}/metadata/`; +const TARGETS_BASE_URL = `${ATTESTATION_TUF_BASE_URL}/targets/`; +const UNPUBLISHED_ROOT_SCHEMA = "https://attestations.trymaple.ai/schemas/unpublished-tuf-root/v1"; +const CHANNEL_SCHEMA = "https://attestations.trymaple.ai/schemas/channel/v1"; +const MANIFEST_SCHEMA = "https://attestations.trymaple.ai/schemas/nitro-eif-release/v1"; +const PCR_HEX_PATTERN = /^[0-9a-f]{96}$/; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; +const ED25519_HEX_PATTERN = /^[0-9a-f]{64}$/; +const ED25519_SIGNATURE_PATTERN = /^[0-9a-f]{128}$/; +const TARGET_PATH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; +const MAX_SAFE_VERSION = Number.MAX_SAFE_INTEGER; +const MAX_ROOT_ROTATIONS = 32; +const FETCH_TIMEOUT_MS = 15_000; +const MAX_TIMESTAMP_VALIDITY_MS = 48 * 60 * 60 * 1000; +const MAX_ROOT_BYTES = 64 * 1024; +const MAX_TIMESTAMP_BYTES = 32 * 1024; +const MAX_SNAPSHOT_BYTES = 128 * 1024; +const MAX_TARGETS_BYTES = 256 * 1024; +const MAX_POLICY_TARGET_BYTES = 128 * 1024; +const MAX_TRUST_ROOT_BYTES = 512 * 1024; +const MAX_MANIFEST_BYTES = 128 * 1024; +const MAX_BUNDLE_BYTES = 2 * 1024 * 1024; +const MAX_CACHE_JSON_CHARS = 8 * 1024 * 1024; +const MAX_CACHED_TARGET_BASE64_CHARS = Math.ceil((MAX_BUNDLE_BYTES * 4) / 3) + 4; +const CACHE_PREFIX = "opensecret:attestation-tuf:v4:"; +const LEGACY_CACHE_PREFIX = "opensecret:attestation-tuf:v3:"; +const OBSERVATION_PREFIX = `${CACHE_PREFIX}repository-observation:`; +const MAX_STORED_GENERATIONS_PER_CHANNEL = 32; +const MAX_STORED_OBSERVATIONS = 128; +const MAX_AUTHORITY_PROVENANCE_KEYS = 128; + +function maximumRootVersionForBootstrap(bootstrapVersion: number): number { + return bootstrapVersion > MAX_SAFE_VERSION - MAX_ROOT_ROTATIONS + ? MAX_SAFE_VERSION + : bootstrapVersion + MAX_ROOT_ROTATIONS; +} + +const EnvironmentSchema = z.enum(["prod", "dev"]); +export type AttestationChannel = z.infer; + +const PositiveVersionSchema = z.number().int().min(1).max(MAX_SAFE_VERSION); +const PositiveSequenceSchema = z.number().int().min(1).max(MAX_SAFE_VERSION); +const ExpirySchema = z.string().datetime({ offset: true }); +const Sha256Schema = z.string().regex(SHA256_HEX_PATTERN); + +const SignatureSchema = z + .object({ + keyid: z.string().min(1).max(128), + sig: z.string().regex(ED25519_SIGNATURE_PATTERN) + }) + .strict(); + +const KeySchema = z + .object({ + keytype: z.literal("ed25519"), + scheme: z.literal("ed25519"), + keyval: z + .object({ + public: z.string().regex(ED25519_HEX_PATTERN) + }) + .strict() + }) + .strict(); + +const RoleSchema = z + .object({ + keyids: z.array(z.string().min(1).max(128)).min(1).max(16), + threshold: z.number().int().min(1).max(16) + }) + .strict() + .superRefine((role, context) => { + if (new Set(role.keyids).size !== role.keyids.length) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "role key IDs must be unique" }); + } + if (role.threshold > role.keyids.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "role threshold exceeds its key count" + }); + } + }); + +const CommonSignedSchema = z.object({ + spec_version: z.string().regex(/^1\.0(?:\.\d+)?$/), + version: PositiveVersionSchema, + expires: ExpirySchema +}); + +const RootSignedSchema = CommonSignedSchema.extend({ + _type: z.literal("root"), + consistent_snapshot: z.literal(true), + keys: z.record(z.string().min(1).max(128), KeySchema), + roles: z + .object({ + root: RoleSchema, + targets: RoleSchema, + snapshot: RoleSchema, + timestamp: RoleSchema + }) + .strict() +}) + .strict() + .superRefine((root, context) => { + const keyEntries = Object.entries(root.keys); + if (keyEntries.length === 0 || keyEntries.length > 32) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["keys"], + message: "root must contain between 1 and 32 keys" + }); + } + for (const [roleName, role] of Object.entries(root.roles)) { + for (const keyid of role.keyids) { + if (!root.keys[keyid]) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["roles", roleName, "keyids"], + message: `role references unknown key ${keyid}` + }); + } + } + } + }); + +const TargetFileSchema = z + .object({ + length: z.number().int().min(0).max(MAX_BUNDLE_BYTES), + hashes: z + .object({ + sha256: Sha256Schema + }) + .strict() + }) + .strict(); + +const MetaFileSchema = z + .object({ + version: PositiveVersionSchema, + length: z.number().int().positive().max(MAX_TARGETS_BYTES), + hashes: z + .object({ + sha256: Sha256Schema + }) + .strict() + }) + .strict(); + +const TimestampSignedSchema = CommonSignedSchema.extend({ + _type: z.literal("timestamp"), + meta: z + .object({ + "snapshot.json": MetaFileSchema + }) + .strict() +}).strict(); + +const SnapshotSignedSchema = CommonSignedSchema.extend({ + _type: z.literal("snapshot"), + meta: z + .object({ + "targets.json": MetaFileSchema + }) + .strict() +}).strict(); + +const TargetsSignedSchema = CommonSignedSchema.extend({ + _type: z.literal("targets"), + targets: z.record(z.string(), TargetFileSchema) +}) + .strict() + .superRefine((targets, context) => { + const paths = Object.keys(targets.targets); + if (paths.length === 0 || paths.length > 256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targets"], + message: "targets metadata must contain between 1 and 256 targets" + }); + } + for (const path of paths) { + if (!isSafeTargetPath(path)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targets", path], + message: "unsafe TUF target path" + }); + } + } + }); + +function envelopeSchema(signed: T) { + return z + .object({ + signatures: z.array(SignatureSchema).min(1).max(32), + signed + }) + .strict(); +} + +const RootEnvelopeSchema = envelopeSchema(RootSignedSchema); +const TimestampEnvelopeSchema = envelopeSchema(TimestampSignedSchema); +const SnapshotEnvelopeSchema = envelopeSchema(SnapshotSignedSchema); +const TargetsEnvelopeSchema = envelopeSchema(TargetsSignedSchema); + +type RootEnvelope = z.infer; +type TimestampEnvelope = z.infer; +type SnapshotEnvelope = z.infer; +type TargetsEnvelope = z.infer; +type RootSigned = RootEnvelope["signed"]; +type TargetFile = z.infer; + +const UnpublishedRootSchema = z + .object({ + schema: z.literal(UNPUBLISHED_ROOT_SCHEMA), + status: z.literal("unpublished"), + message: z.string().min(1) + }) + .strict(); + +const TargetReferenceSchema = z + .object({ + path: z.string().refine(isSafeTargetPath), + sha256: Sha256Schema + }) + .strict(); + +const ActiveReleaseSchema = z + .object({ + manifestTarget: z.string().refine(isSafeTargetPath), + manifestSha256: Sha256Schema, + bundleTarget: z.string().refine(isSafeTargetPath), + bundleSha256: Sha256Schema + }) + .strict(); + +const ChannelSchema = z + .object({ + schema: z.literal(CHANNEL_SCHEMA), + environment: EnvironmentSchema, + sequence: PositiveSequenceSchema, + sigstoreTrustedRootTarget: TargetReferenceSchema, + active: z.array(ActiveReleaseSchema).max(2) + }) + .strict() + .superRefine((channel, context) => { + const manifests = new Set(); + const bundles = new Set(); + channel.active.forEach((release, index) => { + if (manifests.has(release.manifestTarget)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["active", index, "manifestTarget"], + message: "duplicate active manifest target" + }); + } + if (bundles.has(release.bundleTarget)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["active", index, "bundleTarget"], + message: "duplicate active Sigstore bundle target" + }); + } + manifests.add(release.manifestTarget); + bundles.add(release.bundleTarget); + }); + }); + +const ManifestSchema = z + .object({ + schema: z.literal(MANIFEST_SCHEMA), + component: z.literal("opensecret-backend"), + environment: EnvironmentSchema, + release: z + .object({ + version: z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/) + }) + .strict(), + source: z + .object({ + uri: z.string().url().refine(isExactHttpsUrl, "source URI must be an exact HTTPS URL"), + path: z.string().min(1).max(512).refine(isSafeSourcePath, "unsafe source path"), + ref: z.string().min(1).max(512), + revision: z + .object({ + algorithm: z.literal("git-sha1"), + digest: z.string().regex(/^[0-9a-f]{40}$/) + }) + .strict() + }) + .strict(), + artifact: z + .object({ + name: z.string().min(1).max(512).refine(isSafeArtifactName, "unsafe artifact name"), + mediaType: z.literal("application/vnd.aws.nitro.eif"), + size: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + digests: z + .object({ + sha256: Sha256Schema + }) + .strict() + }) + .strict(), + measurements: z + .object({ + algorithm: z.literal("sha384"), + requiredPcrs: z.tuple([z.literal(0), z.literal(1), z.literal(2)]), + pcrs: z + .object({ + "0": z.string().regex(PCR_HEX_PATTERN).refine(notAllZero), + "1": z.string().regex(PCR_HEX_PATTERN).refine(notAllZero), + "2": z.string().regex(PCR_HEX_PATTERN).refine(notAllZero) + }) + .strict() + }) + .strict(), + build: z + .object({ + system: z.literal("nix"), + builderId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/), + derivation: z.string().min(1).max(256), + flakeLockSha256: Sha256Schema, + runUri: z.string().url().refine(isExactHttpsUrl, "build run URI must be an exact HTTPS URL") + }) + .strict() + }) + .strict(); + +export type NitroReleaseManifest = z.infer; + +export type SigstoreEvidence = { + bundleTarget: string; + bundleSha256: string; + trustedRootTarget: string; + trustedRootSha256: string; + transparencyLog: { + logIndex: string; + logId: string; + }; + observerTimestamp: string; +}; + +export type TrustedTufRelease = { + manifestTarget: string; + manifestSha256: string; + manifest: NitroReleaseManifest; + sigstore: SigstoreEvidence; +}; + +export type VerifiedAttestationPolicy = { + environment: AttestationChannel; + sequence: number; + policyId: string; + metadataVersions: { + root: number; + timestamp: number; + snapshot: number; + targets: number; + }; + expires: { + root: string; + timestamp: string; + snapshot: string; + targets: string; + }; + releases: readonly TrustedTufRelease[]; +}; + +const RoleAuthoritySchema = z + .object({ + threshold: z.number().int().min(1).max(16), + keyFingerprints: z.array(Sha256Schema).min(1).max(16) + }) + .strict() + .superRefine((authority, context) => { + if (authority.threshold > authority.keyFingerprints.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "role authority threshold exceeds its key count" + }); + } + for (let index = 1; index < authority.keyFingerprints.length; index += 1) { + if (authority.keyFingerprints[index - 1] >= authority.keyFingerprints[index]) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["keyFingerprints", index], + message: "role authority key fingerprints must be unique and sorted" + }); + } + } + }); + +const AuthorityFingerprintHistorySchema = z + .array(Sha256Schema) + .min(1) + .max(MAX_AUTHORITY_PROVENANCE_KEYS) + .superRefine((fingerprints, context) => { + for (let index = 1; index < fingerprints.length; index += 1) { + if (fingerprints[index - 1] >= fingerprints[index]) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [index], + message: "authority key fingerprints must be unique and sorted" + }); + } + } + }); + +const AuthorityProvenanceSchema = z + .object({ + keyFingerprints: AuthorityFingerprintHistorySchema + }) + .strict(); + +// Authority history enforces two lifetime rules across every authenticated root: +// online-role key material is one-way retired, and offline root material never +// crosses into an online role (or vice versa). +const AuthorityHistorySchema = z + .object({ + root: AuthorityFingerprintHistorySchema, + timestamp: AuthorityFingerprintHistorySchema, + snapshot: AuthorityFingerprintHistorySchema, + targets: AuthorityFingerprintHistorySchema + }) + .strict() + .superRefine((history, context) => { + const offline = new Set(history.root); + for (const role of ["timestamp", "snapshot", "targets"] as const) { + const index = history[role].findIndex((fingerprint) => offline.has(fingerprint)); + if (index !== -1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [role, index], + message: "offline root and online authority history must be disjoint" + }); + } + } + }); + +const RootHighWaterSchema = z + .object({ + version: PositiveVersionSchema, + sha256: Sha256Schema + }) + .strict(); + +const RootHistorySchema = z + .array(RootHighWaterSchema) + .min(1) + .max(256) + .superRefine((history, context) => { + for (let index = 1; index < history.length; index += 1) { + if (history[index].version !== history[index - 1].version + 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, "version"], + message: "root history versions must be sequential" + }); + } + } + }); + +const MetadataHighWaterSchema = RootHighWaterSchema.extend({ + authority: AuthorityProvenanceSchema +}).strict(); + +const DescriptorHighWaterSchema = RootHighWaterSchema.extend({ + parentAuthority: AuthorityProvenanceSchema, + childAuthority: AuthorityProvenanceSchema +}).strict(); + +const RepositoryHighWaterSchema = z + .object({ + root: RootHighWaterSchema, + rootHistory: RootHistorySchema, + authorities: z + .object({ + root: RoleAuthoritySchema, + timestamp: RoleAuthoritySchema, + snapshot: RoleAuthoritySchema, + targets: RoleAuthoritySchema + }) + .strict(), + authorityHistory: AuthorityHistorySchema, + timestamp: MetadataHighWaterSchema.optional(), + snapshotDescriptor: DescriptorHighWaterSchema.optional(), + snapshot: MetadataHighWaterSchema.optional(), + targetsDescriptor: DescriptorHighWaterSchema.optional(), + targets: MetadataHighWaterSchema.optional() + }) + .strict() + .superRefine((repository, context) => { + const current = repository.rootHistory[repository.rootHistory.length - 1]; + if ( + !current || + current.version !== repository.root.version || + current.sha256 !== repository.root.sha256 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["rootHistory"], + message: "root history must end at the current root high-water mark" + }); + } + }); + +const ChannelHighWaterSchema = z + .object({ + sequence: PositiveSequenceSchema, + policyId: Sha256Schema, + authority: AuthorityProvenanceSchema + }) + .strict(); + +const ChannelHighWatersSchema = z + .object({ + prod: ChannelHighWaterSchema.optional(), + dev: ChannelHighWaterSchema.optional() + }) + .strict(); + +type RoleAuthority = z.infer; +type RootHighWater = z.infer; +type AuthorityProvenance = z.infer; +type AuthorityHistory = z.infer; +type MetadataHighWater = z.infer; +type DescriptorHighWater = z.infer; +type RepositoryHighWater = z.infer; +type ChannelHighWater = z.infer; +type ChannelHighWaters = z.infer; + +type LegacyRawGeneration = { + version: 2; + trustedRootVersion: number; + environment: AttestationChannel; + rootChain: string[]; + timestamp: string; + snapshot: string; + targets: string; + targetBytes: Record; +}; + +type RawGeneration = Omit & { + version: 4; + repositoryHighWater: RepositoryHighWater; + channelHighWater: ChannelHighWater; +}; + +const LegacyRawGenerationSchema = z + .object({ + version: z.literal(2), + trustedRootVersion: PositiveVersionSchema, + environment: EnvironmentSchema, + rootChain: z.array(z.string().min(1).max(MAX_ROOT_BYTES)).max(256), + timestamp: z.string().min(1).max(MAX_TIMESTAMP_BYTES), + snapshot: z.string().min(1).max(MAX_SNAPSHOT_BYTES), + targets: z.string().min(1).max(MAX_TARGETS_BYTES), + targetBytes: z.record(z.string(), z.string().max(MAX_CACHED_TARGET_BASE64_CHARS)) + }) + .strict() + .superRefine((generation, context) => { + const paths = Object.keys(generation.targetBytes); + if (paths.length > 7 || paths.some((path) => !isSafeTargetPath(path))) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targetBytes"], + message: "cached target set is not bounded or contains an unsafe path" + }); + } + }); + +const RawGenerationSchema = z + .object({ + version: z.literal(4), + trustedRootVersion: PositiveVersionSchema, + environment: EnvironmentSchema, + rootChain: z.array(z.string().min(1).max(MAX_ROOT_BYTES)).max(256), + timestamp: z.string().min(1).max(MAX_TIMESTAMP_BYTES), + snapshot: z.string().min(1).max(MAX_SNAPSHOT_BYTES), + targets: z.string().min(1).max(MAX_TARGETS_BYTES), + targetBytes: z.record(z.string(), z.string().max(MAX_CACHED_TARGET_BASE64_CHARS)), + repositoryHighWater: RepositoryHighWaterSchema, + channelHighWater: ChannelHighWaterSchema + }) + .strict() + .superRefine((generation, context) => { + const paths = Object.keys(generation.targetBytes); + if (paths.length > 7 || paths.some((path) => !isSafeTargetPath(path))) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targetBytes"], + message: "cached target set is not bounded or contains an unsafe path" + }); + } + }); + +const AnyRawGenerationSchema = z.union([RawGenerationSchema, LegacyRawGenerationSchema]); + +export type AttestationTufClientOptions = { + fetch?: typeof fetch; + storage?: Storage | null; + now?: () => Date; + bootstrap?: unknown; +}; + +type VerifiedGeneration = { + raw: RawGeneration | LegacyRawGeneration; + root: RootEnvelope; + timestamp: TimestampEnvelope; + snapshot: SnapshotEnvelope; + targets: TargetsEnvelope; + policy: VerifiedAttestationPolicy; + repositoryHighWater: RepositoryHighWater; + channelHighWater: ChannelHighWater; +}; + +type LegacyRawObservation = { + version: 1; + trustedRootVersion: number; + rootChain: string[]; + timestamp?: string; + snapshot?: string; + targets?: string; +}; + +type RawObservation = Omit & { + version: 3; + repositoryHighWater: RepositoryHighWater; + channelHighWater: ChannelHighWaters; +}; + +type RawObservationDraft = Omit; + +const LegacyRawObservationSchema = z + .object({ + version: z.literal(1), + trustedRootVersion: PositiveVersionSchema, + rootChain: z.array(z.string().min(1).max(MAX_ROOT_BYTES)).max(256), + timestamp: z.string().min(1).max(MAX_TIMESTAMP_BYTES).optional(), + snapshot: z.string().min(1).max(MAX_SNAPSHOT_BYTES).optional(), + targets: z.string().min(1).max(MAX_TARGETS_BYTES).optional() + }) + .strict() + .superRefine((observation, context) => { + if (observation.snapshot && !observation.timestamp) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "snapshot requires timestamp" }); + } + if (observation.targets && !observation.snapshot) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "targets require snapshot" }); + } + }); + +const RawObservationSchema = z + .object({ + version: z.literal(3), + trustedRootVersion: PositiveVersionSchema, + rootChain: z.array(z.string().min(1).max(MAX_ROOT_BYTES)).max(256), + timestamp: z.string().min(1).max(MAX_TIMESTAMP_BYTES).optional(), + snapshot: z.string().min(1).max(MAX_SNAPSHOT_BYTES).optional(), + targets: z.string().min(1).max(MAX_TARGETS_BYTES).optional(), + repositoryHighWater: RepositoryHighWaterSchema, + channelHighWater: ChannelHighWatersSchema + }) + .strict() + .superRefine((observation, context) => { + if (observation.snapshot && !observation.timestamp) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "snapshot requires timestamp" }); + } + if (observation.targets && !observation.snapshot) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "targets require snapshot" }); + } + }); + +const AnyRawObservationSchema = z.union([RawObservationSchema, LegacyRawObservationSchema]); + +type VerifiedObservation = { + raw: RawObservation | LegacyRawObservation; + root: RootEnvelope; + timestamp?: TimestampEnvelope; + snapshot?: SnapshotEnvelope; + targets?: TargetsEnvelope; + repositoryHighWater: RepositoryHighWater; + channelHighWater: ChannelHighWaters; +}; + +type StoredGeneration = { + key: string; + raw: unknown; +}; + +function sameKeys(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((key, index) => key === right[index]); +} + +function stableStorageSnapshot( + storage: Storage, + prefix: string, + maximumEntries: number, + description: string +): Array<{ key: string; value: string }> { + const enumerate = (): string[] => { + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(prefix)) keys.push(key); + } + keys.sort(); + if (keys.length > maximumEntries) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `${description} contains too many entries.` + ); + } + return keys; + }; + + for (let attempt = 0; attempt < 4; attempt += 1) { + const before = enumerate(); + const entries: Array<{ key: string; value: string }> = []; + let missing = false; + for (const key of before) { + const value = storage.getItem(key); + if (value === null) { + missing = true; + break; + } + entries.push({ key, value }); + } + const after = enumerate(); + if (!missing && sameKeys(before, after)) return entries; + } + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `${description} changed repeatedly while it was being read.` + ); +} + +export class AttestationTrustError extends Error { + readonly code: string; + + constructor(code: string, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "AttestationTrustError"; + this.code = code; + } +} + +class TrustNetworkError extends AttestationTrustError { + constructor(message: string, options?: ErrorOptions) { + super("TRUST_NETWORK_UNAVAILABLE", message, options); + } +} + +function notAllZero(value: string): boolean { + return !/^0+$/.test(value); +} + +function isSafeTargetPath(path: string): boolean { + if ( + path.length === 0 || + path.length > 1024 || + !TARGET_PATH_PATTERN.test(path) || + path.startsWith("/") || + path.endsWith("/") || + path.includes("\\") || + path.includes("%") || + path.includes("//") + ) { + return false; + } + return path.split("/").every((part) => part !== "." && part !== ".."); +} + +function isExactHttpsUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +function isSafeSourcePath(path: string): boolean { + if (path === ".") return true; + return ( + path.length <= 512 && + !path.startsWith("/") && + !path.endsWith("/") && + !path.includes("\\") && + !path.includes("%") && + !path.includes("//") && + path.split("/").every((part) => part !== "" && part !== "." && part !== "..") + ); +} + +function isSafeArtifactName(name: string): boolean { + return ( + name !== "." && + name !== ".." && + !name.includes("/") && + !name.includes("\\") && + !name.includes("%") + ); +} + +function fromHex(value: string): Uint8Array { + const bytes = new Uint8Array(value.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value !== null && typeof value === "object") { + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalize(record[key])]) + ); + } + return value; +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value)) deepFreeze(nested); + Object.freeze(value); + } + return value; +} + +export function canonicalJsonBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(canonicalize(value))); +} + +async function sha256(bytes: Uint8Array): Promise { + return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))); +} + +function parseJson(raw: Uint8Array, schema: z.ZodType, description: string): T { + let value: unknown; + try { + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); + } catch (error) { + throw new AttestationTrustError("TUF_METADATA_INVALID", `${description} is not valid JSON.`, { + cause: error + }); + } + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new AttestationTrustError( + "TUF_METADATA_INVALID", + `${description} does not match the supported TUF v1 profile.`, + { cause: parsed.error } + ); + } + return parsed.data; +} + +function parseJsonString(raw: string, schema: z.ZodType, description: string): T { + return parseJson(new TextEncoder().encode(raw), schema, description); +} + +function assertUnexpired(expires: string, now: Date, role: string): void { + const expiry = Date.parse(expires); + if (!Number.isFinite(expiry) || expiry <= now.getTime()) { + throw new AttestationTrustError("TUF_EXPIRED", `${role} metadata is expired.`); + } +} + +async function assertRootKeyIds(root: RootSigned): Promise { + for (const [keyid, key] of Object.entries(root.keys)) { + const actual = await sha256(canonicalJsonBytes(key)); + if (actual !== keyid) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF root key ID ${keyid} does not match its key material.` + ); + } + } +} + +async function rootRoleAuthority( + root: RootSigned, + roleName: keyof RootSigned["roles"] +): Promise { + const role = root.roles[roleName]; + const keyFingerprints = await Promise.all( + role.keyids.map(async (keyid) => { + const key = root.keys[keyid]; + if (!key) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF ${roleName} role references unknown key ${keyid}.` + ); + } + // Authority provenance follows normalized verification-key material, not + // TUF key IDs or the signatures a mirror happened to retain. + return await sha256(fromHex(key.keyval.public)); + }) + ); + keyFingerprints.sort(); + if (keyFingerprints.some((fingerprint, index) => fingerprint === keyFingerprints[index - 1])) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF ${roleName} role authorizes duplicate aliases for the same key material.` + ); + } + return RoleAuthoritySchema.parse({ threshold: role.threshold, keyFingerprints }); +} + +async function rootRoleAuthorities(root: RootSigned): Promise { + const [offline, timestamp, snapshot, targets] = await Promise.all([ + rootRoleAuthority(root, "root"), + rootRoleAuthority(root, "timestamp"), + rootRoleAuthority(root, "snapshot"), + rootRoleAuthority(root, "targets") + ]); + return { root: offline, timestamp, snapshot, targets }; +} + +async function assertRootAuthorities(root: RootSigned): Promise { + const authorities = await rootRoleAuthorities(root); + const offlineKeys = new Set(authorities.root.keyFingerprints); + for (const [role, authority] of [ + ["timestamp", authorities.timestamp], + ["snapshot", authorities.snapshot], + ["targets", authorities.targets] + ] as const) { + const overlap = authority.keyFingerprints.find((fingerprint) => offlineKeys.has(fingerprint)); + if (overlap) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF offline root and ${role} role reuse key material ${overlap}.` + ); + } + } +} + +function assertThreshold( + envelope: { signatures: Array<{ keyid: string; sig: string }>; signed: unknown }, + trustedRoot: RootSigned, + roleName: keyof RootSigned["roles"] +): void { + const role = trustedRoot.roles[roleName]; + const signedBytes = canonicalJsonBytes(envelope.signed); + const verifiedKeyIds = new Set(); + + for (const signature of envelope.signatures) { + if (verifiedKeyIds.has(signature.keyid) || !role.keyids.includes(signature.keyid)) continue; + const key = trustedRoot.keys[signature.keyid]; + if ( + key && + nacl.sign.detached.verify(signedBytes, fromHex(signature.sig), fromHex(key.keyval.public)) + ) { + verifiedKeyIds.add(signature.keyid); + } + } + + if (verifiedKeyIds.size < role.threshold) { + throw new AttestationTrustError( + "TUF_SIGNATURE_INVALID", + `${roleName} metadata does not meet its trusted signature threshold.` + ); + } +} + +async function assertBytesMatch( + bytes: Uint8Array, + descriptor: { length: number; hashes: { sha256: string } }, + description: string +): Promise { + if (bytes.byteLength !== descriptor.length) { + throw new AttestationTrustError( + "TUF_TARGET_INTEGRITY", + `${description} length does not match authenticated metadata.` + ); + } + if ((await sha256(bytes)) !== descriptor.hashes.sha256) { + throw new AttestationTrustError( + "TUF_TARGET_INTEGRITY", + `${description} SHA-256 does not match authenticated metadata.` + ); + } +} + +function metadataUrl(name: string): URL { + return new URL(name, METADATA_BASE_URL); +} + +function targetUrl(path: string, sha256Digest: string): URL { + if (!isSafeTargetPath(path)) { + throw new AttestationTrustError("POLICY_INVALID", "Policy contains an unsafe target path."); + } + const separator = path.lastIndexOf("/"); + const directory = separator === -1 ? "" : path.slice(0, separator + 1); + const basename = path.slice(separator + 1); + return new URL(`${directory}${sha256Digest}.${basename}`, TARGETS_BASE_URL); +} + +function readChunkWithAbort( + reader: ReadableStreamDefaultReader, + signal: AbortSignal, + description: string +): Promise> { + if (signal.aborted) return Promise.reject(new TrustNetworkError(`${description} timed out.`)); + return new Promise((resolve, reject) => { + const onAbort = () => { + void reader.cancel(); + reject(new TrustNetworkError(`${description} timed out.`)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + reader + .read() + .then(resolve, (error) => { + reject(new TrustNetworkError(`${description} response was interrupted.`, { cause: error })); + }) + .finally(() => signal.removeEventListener("abort", onAbort)); + }); +} + +async function readBoundedResponse( + response: Response, + requestedUrl: URL, + maxBytes: number, + description: string, + signal: AbortSignal +): Promise { + if (response.redirected) { + throw new AttestationTrustError("TRUST_REDIRECT", `${description} redirected unexpectedly.`); + } + + if (!response.url) { + throw new AttestationTrustError( + "TRUST_REDIRECT", + `${description} did not expose its final attestation repository URL.` + ); + } + + { + let finalUrl: URL; + try { + finalUrl = new URL(response.url); + } catch (error) { + throw new AttestationTrustError("TRUST_REDIRECT", `${description} returned an invalid URL.`, { + cause: error + }); + } + if ( + finalUrl.origin !== requestedUrl.origin || + finalUrl.pathname !== requestedUrl.pathname || + finalUrl.search !== "" || + finalUrl.hash !== "" + ) { + throw new AttestationTrustError( + "TRUST_REDIRECT", + `${description} did not remain on its exact attestation repository URL.` + ); + } + } + + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + if (!/^(0|[1-9]\d*)$/.test(contentLength)) { + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} returned an invalid Content-Length.` + ); + } + const declaredLength = Number(contentLength); + if (!Number.isSafeInteger(declaredLength) || declaredLength > maxBytes) { + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} exceeds the ${maxBytes}-byte limit.` + ); + } + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} did not provide a stream that can be bounded before allocation.` + ); + } + + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await readChunkWithAbort(reader, signal, description); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} exceeds the ${maxBytes}-byte limit.` + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +async function fetchBytes( + fetcher: typeof fetch, + url: URL, + maxBytes: number, + description: string, + allowNotFound = false +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetcher(url.href, { + method: "GET", + credentials: "omit", + redirect: "error", + cache: "no-store", + referrerPolicy: "no-referrer", + headers: { accept: "application/json" }, + signal: controller.signal + }); + if (response.redirected) { + throw new AttestationTrustError("TRUST_REDIRECT", `${description} redirected unexpectedly.`); + } + if (!response.url) { + throw new AttestationTrustError( + "TRUST_REDIRECT", + `${description} did not expose its final attestation repository URL.` + ); + } + const finalUrl = new URL(response.url); + if ( + finalUrl.origin !== url.origin || + finalUrl.pathname !== url.pathname || + finalUrl.search !== "" || + finalUrl.hash !== "" + ) { + throw new AttestationTrustError( + "TRUST_REDIRECT", + `${description} did not remain on its exact attestation repository URL.` + ); + } + if (allowNotFound && response.status === 404) return null; + if ( + response.status === 404 || + response.status === 408 || + response.status === 429 || + response.status >= 500 + ) { + throw new TrustNetworkError( + `${description} is temporarily unavailable (HTTP ${response.status}).` + ); + } + if (!response.ok) { + throw new AttestationTrustError( + "TRUST_HTTP_ERROR", + `${description} returned HTTP ${response.status}.` + ); + } + return await readBoundedResponse(response, url, maxBytes, description, controller.signal); + } catch (error) { + if (error instanceof AttestationTrustError) throw error; + if (controller.signal.aborted) { + throw new TrustNetworkError(`${description} timed out.`, { cause: error }); + } + // Fetch exposes redirect:"error" and offline failures as the same TypeError. + // Ambiguous rejections fail closed so a redirect can never select stale policy. + throw new AttestationTrustError("TRUST_FETCH_FAILED", `${description} fetch failed.`, { + cause: error + }); + } finally { + clearTimeout(timeout); + } +} + +function rootFromBootstrap(bootstrapValue: unknown): RootEnvelope { + if (UnpublishedRootSchema.safeParse(bootstrapValue).success) { + throw new AttestationTrustError( + "TUF_BOOTSTRAP_INVALID", + "The SDK attestation TUF root has not been bootstrapped for production yet." + ); + } + const root = RootEnvelopeSchema.safeParse(bootstrapValue); + if (!root.success) { + throw new AttestationTrustError( + "TUF_BOOTSTRAP_INVALID", + "The embedded attestation TUF root is invalid.", + { cause: root.error } + ); + } + return root.data; +} + +function assertOfficialEmbeddedBootstrap(bootstrapValue: unknown): void { + if (UnpublishedRootSchema.safeParse(bootstrapValue).success) return; + const root = rootFromBootstrap(bootstrapValue); + if (root.signed.version !== 1) { + throw new AttestationTrustError( + "TUF_BOOTSTRAP_INVALID", + "The official SDK attestation TUF bootstrap must remain root version 1." + ); + } +} + +async function verifyInitialRoot(root: RootEnvelope): Promise { + await assertRootKeyIds(root.signed); + await assertRootAuthorities(root.signed); + assertThreshold(root, root.signed, "root"); +} + +async function verifyNextRoot(previous: RootEnvelope, next: RootEnvelope): Promise { + if (next.signed.version !== previous.signed.version + 1) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + "TUF root versions must rotate one at a time." + ); + } + assertThreshold(next, previous.signed, "root"); + await assertRootKeyIds(next.signed); + await assertRootAuthorities(next.signed); + assertThreshold(next, next.signed, "root"); +} + +async function rootHighWater(root: RootEnvelope): Promise { + return { + version: root.signed.version, + sha256: await sha256(canonicalJsonBytes(root.signed)) + }; +} + +async function restoreRootChain( + bootstrap: RootEnvelope, + rawRootChain: readonly string[], + trustedRootVersion: number +): Promise<{ + root: RootEnvelope; + rootHistory: RootHighWater[]; + authorityHistory: AuthorityHistory; +}> { + await verifyInitialRoot(bootstrap); + if (trustedRootVersion !== bootstrap.signed.version) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + "Cached trust state was created from a different embedded TUF root trust epoch." + ); + } + let root = bootstrap; + const maximumRootVersion = maximumRootVersionForBootstrap(bootstrap.signed.version); + const rootHistory = [await rootHighWater(bootstrap)]; + let authorities = await rootRoleAuthorities(bootstrap.signed); + let authorityHistory = authorityHistoryFromAuthorities(authorities); + for (const [index, raw] of rawRootChain.entries()) { + const next = parseJsonString(raw, RootEnvelopeSchema, `cached root ${index + 1}`); + if (next.signed.version < bootstrap.signed.version) continue; + if (next.signed.version === bootstrap.signed.version) { + if (!sameSignedPayload(next, bootstrap)) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + "Cached root conflicts with the embedded root at the same version." + ); + } + continue; + } + if (next.signed.version > maximumRootVersion) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF root rotation exceeds the ${MAX_ROOT_ROTATIONS}-version embedded-bootstrap limit.` + ); + } + await verifyNextRoot(root, next); + const nextAuthorities = await rootRoleAuthorities(next.signed); + authorityHistory = advanceAuthorityHistoryValues( + authorities, + authorityHistory, + nextAuthorities, + authorityHistoryFromAuthorities(nextAuthorities) + ); + authorities = nextAuthorities; + root = next; + rootHistory.push(await rootHighWater(next)); + } + return { root, rootHistory, authorityHistory }; +} + +async function refreshRootChain( + fetcher: typeof fetch, + initial: RootEnvelope, + initialChain: readonly string[], + maximumRootVersion: number, + onRootAuthenticated?: (root: RootEnvelope, rootChain: readonly string[]) => Promise +): Promise<{ root: RootEnvelope; rootChain: string[] }> { + let root = initial; + const rootChain = [...initialChain]; + if (root.signed.version > maximumRootVersion) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF root rotation exceeds the ${MAX_ROOT_ROTATIONS}-version embedded-bootstrap limit.` + ); + } + + while (root.signed.version < maximumRootVersion) { + const nextVersion = root.signed.version + 1; + const bytes = await fetchBytes( + fetcher, + metadataUrl(`${nextVersion}.root.json`), + MAX_ROOT_BYTES, + `TUF root ${nextVersion}`, + true + ); + if (bytes === null) { + return { root, rootChain }; + } + const next = parseJson(bytes, RootEnvelopeSchema, `TUF root ${nextVersion}`); + await verifyNextRoot(root, next); + rootChain.push(new TextDecoder().decode(bytes)); + root = next; + await onRootAuthenticated?.(root, rootChain); + } + + // Probe one version beyond the absolute bootstrap-relative ceiling without + // parsing, authenticating, or persisting it. A mirror cannot turn repeated + // refreshes into an unbounded sequence of individually valid 32-root hops. + if (maximumRootVersion === MAX_SAFE_VERSION) return { root, rootChain }; + const sentinelVersion = maximumRootVersion + 1; + let sentinel: Uint8Array | null; + try { + sentinel = await fetchBytes( + fetcher, + metadataUrl(`${sentinelVersion}.root.json`), + MAX_ROOT_BYTES, + `TUF root ${sentinelVersion}`, + true + ); + } catch (error) { + // At the trust-epoch ceiling, only an exact 404 proves that no forbidden + // next root exists. Do not let transient/ambiguous probe failures select an + // older cached policy through the ordinary network-fallback path. + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF root ${sentinelVersion} absence could not be proven at the embedded-bootstrap limit.`, + { cause: error } + ); + } + if (sentinel !== null) { + throw new AttestationTrustError( + "TUF_ROOT_CHAIN_INVALID", + `TUF root rotation exceeds the ${MAX_ROOT_ROTATIONS}-version embedded-bootstrap limit.` + ); + } + return { root, rootChain }; +} + +function assertMetadataVersion( + actual: number, + expected: number, + role: string, + minimum?: number +): void { + if (actual !== expected) { + throw new AttestationTrustError( + "TUF_MIX_AND_MATCH", + `${role} metadata version does not match its authenticated reference.` + ); + } + if (minimum !== undefined && actual < minimum) { + throw new AttestationTrustError("TUF_ROLLBACK", `${role} metadata rolled back.`); + } +} + +function rawBytesFromCache(raw: RawGeneration | LegacyRawGeneration, path: string): Uint8Array { + const encoded = raw.targetBytes[path]; + if (typeof encoded !== "string") { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `The verified cache is missing target ${path}.` + ); + } + let bytes: Uint8Array; + try { + bytes = decodeBase64(encoded); + } catch (error) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `Cached target ${path} is not valid base64.`, + { cause: error } + ); + } + return bytes; +} + +function targetDescriptor(targets: TargetsEnvelope, path: string): TargetFile { + const descriptor = targets.signed.targets[path]; + if (!descriptor) { + throw new AttestationTrustError( + "POLICY_INVALID", + `Policy references target ${path}, which targets metadata does not authorize.` + ); + } + return descriptor; +} + +function releaseVersionFromTarget( + path: string, + environment: AttestationChannel, + filename: "manifest.json" | "manifest.sigstore.json" +): string { + const parts = path.split("/"); + if ( + parts.length !== 4 || + parts[0] !== "releases" || + parts[2] !== environment || + parts[3] !== filename || + !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(parts[1]) + ) { + throw new AttestationTrustError( + "POLICY_INVALID", + `Release target ${path} does not belong to the ${environment} channel.` + ); + } + return parts[1]; +} + +async function verifyCachedTarget( + raw: RawGeneration | LegacyRawGeneration, + targets: TargetsEnvelope, + path: string, + expectedSha256: string | undefined, + maxBytes: number, + description: string +): Promise { + const descriptor = targetDescriptor(targets, path); + if (descriptor.length > maxBytes) { + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} exceeds the ${maxBytes}-byte limit.` + ); + } + if (expectedSha256 !== undefined && descriptor.hashes.sha256 !== expectedSha256) { + throw new AttestationTrustError( + "TUF_TARGET_INTEGRITY", + `${description} digest does not match its channel reference.` + ); + } + const bytes = rawBytesFromCache(raw, path); + await assertBytesMatch(bytes, descriptor, description); + return bytes; +} + +async function policyFromTargets( + raw: RawGeneration | LegacyRawGeneration, + root: RootEnvelope, + timestamp: TimestampEnvelope, + snapshot: SnapshotEnvelope, + targets: TargetsEnvelope +): Promise { + const channelPath = `channels/${raw.environment}.json`; + const channelBytes = await verifyCachedTarget( + raw, + targets, + channelPath, + undefined, + MAX_POLICY_TARGET_BYTES, + `${raw.environment} channel` + ); + const channel = parseJson(channelBytes, ChannelSchema, `${raw.environment} channel`); + if (channel.environment !== raw.environment) { + throw new AttestationTrustError( + "POLICY_ENVIRONMENT_MISMATCH", + `The ${raw.environment} channel contains ${channel.environment} policy.` + ); + } + if (channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json") { + throw new AttestationTrustError( + "POLICY_INVALID", + "sigstoreTrustedRootTarget.path must be sigstore/trusted_root.json." + ); + } + + const trustedRootBytes = await verifyCachedTarget( + raw, + targets, + channel.sigstoreTrustedRootTarget.path, + channel.sigstoreTrustedRootTarget.sha256, + MAX_TRUST_ROOT_BYTES, + "Sigstore trusted root" + ); + const expectedCachedTargets = new Set([channelPath, channel.sigstoreTrustedRootTarget.path]); + + const releases: TrustedTufRelease[] = []; + const releaseVersions = new Set(); + const pcrTuples = new Set(); + for (const active of channel.active) { + expectedCachedTargets.add(active.manifestTarget); + const releaseVersion = releaseVersionFromTarget( + active.manifestTarget, + raw.environment, + "manifest.json" + ); + const bundleVersion = releaseVersionFromTarget( + active.bundleTarget, + raw.environment, + "manifest.sigstore.json" + ); + if (releaseVersion !== bundleVersion || releaseVersions.has(releaseVersion)) { + throw new AttestationTrustError( + "POLICY_INVALID", + "Active release targets identify different or duplicate releases." + ); + } + releaseVersions.add(releaseVersion); + const manifestBytes = await verifyCachedTarget( + raw, + targets, + active.manifestTarget, + active.manifestSha256, + MAX_MANIFEST_BYTES, + "release manifest" + ); + const manifest = parseJson(manifestBytes, ManifestSchema, "release manifest"); + if (manifest.environment !== raw.environment) { + throw new AttestationTrustError( + "POLICY_ENVIRONMENT_MISMATCH", + `A ${raw.environment} channel manifest declares ${manifest.environment}.` + ); + } + if ( + manifest.release.version !== releaseVersion || + manifest.source.ref !== `refs/tags/v${releaseVersion}` + ) { + throw new AttestationTrustError( + "POLICY_INVALID", + "Release manifest version or source ref does not match its target path." + ); + } + expectedCachedTargets.add(active.bundleTarget); + const bundleBytes = await verifyCachedTarget( + raw, + targets, + active.bundleTarget, + active.bundleSha256, + MAX_BUNDLE_BYTES, + "Sigstore bundle" + ); + let verifiedSigstore; + try { + verifiedSigstore = await verifyTufAuthorizedSigstoreBundle( + manifestBytes, + bundleBytes, + trustedRootBytes + ); + } catch (error) { + throw new AttestationTrustError( + "SIGSTORE_VERIFICATION_FAILED", + "The TUF-authorized release manifest failed local Sigstore verification.", + { cause: error } + ); + } + const tuple = [ + manifest.measurements.pcrs["0"], + manifest.measurements.pcrs["1"], + manifest.measurements.pcrs["2"] + ].join(":"); + if (pcrTuples.has(tuple)) { + throw new AttestationTrustError( + "POLICY_INVALID", + "Two active releases contain the same PCR0/PCR1/PCR2 tuple." + ); + } + pcrTuples.add(tuple); + releases.push({ + manifestTarget: active.manifestTarget, + manifestSha256: active.manifestSha256, + manifest, + sigstore: { + bundleTarget: active.bundleTarget, + bundleSha256: active.bundleSha256, + trustedRootTarget: channel.sigstoreTrustedRootTarget.path, + trustedRootSha256: channel.sigstoreTrustedRootTarget.sha256, + transparencyLog: { + logIndex: verifiedSigstore.logIndex, + logId: verifiedSigstore.logId + }, + observerTimestamp: verifiedSigstore.observerTimestamp + } + }); + } + + const cachedPaths = Object.keys(raw.targetBytes); + if ( + cachedPaths.length !== expectedCachedTargets.size || + cachedPaths.some((path) => !expectedCachedTargets.has(path)) + ) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Attestation policy cache contains unexpected or missing target bodies." + ); + } + + const policyId = await sha256(channelBytes); + + return deepFreeze({ + environment: raw.environment, + sequence: channel.sequence, + policyId, + metadataVersions: { + root: root.signed.version, + timestamp: timestamp.signed.version, + snapshot: snapshot.signed.version, + targets: targets.signed.version + }, + expires: { + root: root.signed.expires, + timestamp: timestamp.signed.expires, + snapshot: snapshot.signed.expires, + targets: targets.signed.expires + }, + releases + }); +} + +type SecurityHighWater = { + repository: RepositoryHighWater; + channels: ChannelHighWaters; +}; + +const REPOSITORY_DIRECT_FLOOR_AUTHORITIES = { + timestamp: "timestamp", + snapshot: "snapshot", + targets: "targets" +} as const satisfies Record< + "timestamp" | "snapshot" | "targets", + keyof RepositoryHighWater["authorities"] +>; + +const REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES = { + snapshotDescriptor: ["timestamp", "snapshot"], + targetsDescriptor: ["snapshot", "targets"] +} as const satisfies Record< + "snapshotDescriptor" | "targetsDescriptor", + readonly [keyof RepositoryHighWater["authorities"], keyof RepositoryHighWater["authorities"]] +>; + +function sameCanonicalValue(left: unknown, right: unknown): boolean { + return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); +} + +async function signedHighWater( + envelope: { signed: { version: number } }, + authority: RoleAuthority +): Promise { + return { + version: envelope.signed.version, + sha256: await sha256(canonicalJsonBytes(envelope.signed)), + authority: provenanceFromAuthority(authority) + }; +} + +async function descriptorHighWater( + descriptor: z.infer, + parentAuthority: RoleAuthority, + childAuthority: RoleAuthority +): Promise { + return { + version: descriptor.version, + sha256: await sha256(canonicalJsonBytes(descriptor)), + parentAuthority: provenanceFromAuthority(parentAuthority), + childAuthority: provenanceFromAuthority(childAuthority) + }; +} + +function provenanceFromAuthority(authority: RoleAuthority): AuthorityProvenance { + return { keyFingerprints: [...authority.keyFingerprints] }; +} + +function authorityHistoryFromAuthorities( + authorities: RepositoryHighWater["authorities"] +): AuthorityHistory { + return { + root: [...authorities.root.keyFingerprints], + timestamp: [...authorities.timestamp.keyFingerprints], + snapshot: [...authorities.snapshot.keyFingerprints], + targets: [...authorities.targets.keyFingerprints] + }; +} + +function unionAuthorityFingerprints(...sets: readonly (readonly string[])[]): string[] { + return AuthorityFingerprintHistorySchema.parse([...new Set(sets.flat())].sort()); +} + +function mergeAuthorityProvenance( + left: AuthorityProvenance, + right: AuthorityProvenance +): AuthorityProvenance { + return { + keyFingerprints: unionAuthorityFingerprints(left.keyFingerprints, right.keyFingerprints) + }; +} + +function unionAuthorityProvenance( + prior: AuthorityProvenance, + candidate: RoleAuthority +): AuthorityProvenance { + return mergeAuthorityProvenance(prior, provenanceFromAuthority(candidate)); +} + +function mergeAuthorityHistories( + left: AuthorityHistory, + right: AuthorityHistory +): AuthorityHistory { + const merged = { + root: unionAuthorityFingerprints(left.root, right.root), + timestamp: unionAuthorityFingerprints(left.timestamp, right.timestamp), + snapshot: unionAuthorityFingerprints(left.snapshot, right.snapshot), + targets: unionAuthorityFingerprints(left.targets, right.targets) + }; + const offline = new Set(merged.root); + const crossed = [...merged.timestamp, ...merged.snapshot, ...merged.targets].find((fingerprint) => + offline.has(fingerprint) + ); + if (crossed) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `TUF key material ${crossed} crosses the offline root and online authority classes.` + ); + } + return merged; +} + +function advanceAuthorityHistoryValues( + priorAuthorities: RepositoryHighWater["authorities"], + priorHistory: AuthorityHistory, + candidateAuthorities: RepositoryHighWater["authorities"], + candidateHistory: AuthorityHistory +): AuthorityHistory { + const priorGlobal = new Set([ + ...priorHistory.timestamp, + ...priorHistory.snapshot, + ...priorHistory.targets + ]); + const advanced = { + root: unionAuthorityFingerprints(priorHistory.root, candidateHistory.root) + } as AuthorityHistory; + for (const role of ["timestamp", "snapshot", "targets"] as const) { + const priorCurrent = new Set(priorAuthorities[role].keyFingerprints); + const reintroduced = candidateAuthorities[role].keyFingerprints.find( + (fingerprint) => priorGlobal.has(fingerprint) && !priorCurrent.has(fingerprint) + ); + if (reintroduced) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `TUF ${role} authority reauthorizes retired key material ${reintroduced}.` + ); + } + advanced[role] = unionAuthorityFingerprints(priorHistory[role], candidateHistory[role]); + } + return mergeAuthorityHistories(priorHistory, advanced); +} + +function advanceAuthorityHistory( + prior: RepositoryHighWater, + candidate: RepositoryHighWater +): AuthorityHistory { + return advanceAuthorityHistoryValues( + prior.authorities, + prior.authorityHistory, + candidate.authorities, + candidate.authorityHistory + ); +} + +async function repositoryHighWaterFromMetadata( + root: RootEnvelope, + rootHistory: RootHighWater[], + authorityHistory: AuthorityHistory, + timestamp?: TimestampEnvelope, + snapshot?: SnapshotEnvelope, + targets?: TargetsEnvelope +): Promise { + const authorities = await rootRoleAuthorities(root.signed); + return { + root: await rootHighWater(root), + rootHistory, + authorities, + authorityHistory, + ...(timestamp + ? { + timestamp: await signedHighWater(timestamp, authorities.timestamp), + snapshotDescriptor: await descriptorHighWater( + timestamp.signed.meta["snapshot.json"], + authorities.timestamp, + authorities.snapshot + ) + } + : {}), + ...(snapshot + ? { + snapshot: await signedHighWater(snapshot, authorities.snapshot), + targetsDescriptor: await descriptorHighWater( + snapshot.signed.meta["targets.json"], + authorities.snapshot, + authorities.targets + ) + } + : {}), + ...(targets ? { targets: await signedHighWater(targets, authorities.targets) } : {}) + }; +} + +function channelHighWaterFromPolicy( + policy: VerifiedAttestationPolicy, + authority: RoleAuthority +): ChannelHighWater { + return { + sequence: policy.sequence, + policyId: policy.policyId, + authority: provenanceFromAuthority(authority) + }; +} + +function safelyReplacesAuthority(prior: AuthorityProvenance, candidate: RoleAuthority): boolean { + const candidateKeys = new Set(candidate.keyFingerprints); + const overlap = prior.keyFingerprints.filter((key) => candidateKeys.has(key)).length; + return overlap < candidate.threshold; +} + +function assertProvenanceCoversAuthority( + role: string, + provenance: AuthorityProvenance, + authority: RoleAuthority +): void { + const provenanceKeys = new Set(provenance.keyFingerprints); + if (authority.keyFingerprints.some((key) => !provenanceKeys.has(key))) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `The persisted ${role} provenance does not cover the current authority.` + ); + } +} + +function assertHighWaterMarkMatches( + role: string, + stored: { version: number; sha256: string } | undefined, + observed: { version: number; sha256: string } | undefined +): void { + if (!observed) return; + if (!stored || stored.version !== observed.version || stored.sha256 !== observed.sha256) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `The persisted ${role} high-water mark does not match its authenticated metadata.` + ); + } +} + +function assertRepositoryHighWaterBoundToMetadata( + stored: RepositoryHighWater, + observed: RepositoryHighWater +): void { + if ( + !sameCanonicalValue(stored.root, observed.root) || + !sameCanonicalValue(stored.authorities, observed.authorities) + ) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The persisted repository high-water state does not match its authenticated TUF root." + ); + } + const normalizedHistory = mergeRootHistories(stored.rootHistory, observed.rootHistory); + if (!sameCanonicalValue(normalizedHistory, stored.rootHistory)) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The persisted root history omits an authenticated sequential root transition." + ); + } + const normalizedAuthorityHistory = mergeAuthorityHistories( + stored.authorityHistory, + observed.authorityHistory + ); + if (!sameCanonicalValue(normalizedAuthorityHistory, stored.authorityHistory)) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The persisted authority history omits an authenticated root transition." + ); + } + assertRepositoryProvenanceCoversAuthorities(stored); + for (const floor of Object.keys(REPOSITORY_DIRECT_FLOOR_AUTHORITIES) as Array< + keyof typeof REPOSITORY_DIRECT_FLOOR_AUTHORITIES + >) { + assertHighWaterMarkMatches(floor, stored[floor], observed[floor]); + } + for (const floor of Object.keys(REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES) as Array< + keyof typeof REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES + >) { + assertHighWaterMarkMatches(floor, stored[floor], observed[floor]); + } +} + +function assertRepositoryProvenanceCoversAuthorities(stored: RepositoryHighWater): void { + const offlineHistory = new Set(stored.authorityHistory.root); + const crossed = [ + ...stored.authorityHistory.timestamp, + ...stored.authorityHistory.snapshot, + ...stored.authorityHistory.targets + ].find((fingerprint) => offlineHistory.has(fingerprint)); + if (crossed) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `Persisted key material ${crossed} crosses the offline root and online authority classes.` + ); + } + for (const role of ["root", "timestamp", "snapshot", "targets"] as const) { + const history = new Set(stored.authorityHistory[role]); + if (stored.authorities[role].keyFingerprints.some((key) => !history.has(key))) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `The persisted ${role} authority history does not cover the current authority.` + ); + } + } + for (const [floor, authorityName] of Object.entries(REPOSITORY_DIRECT_FLOOR_AUTHORITIES) as Array< + [keyof typeof REPOSITORY_DIRECT_FLOOR_AUTHORITIES, keyof RepositoryHighWater["authorities"]] + >) { + const storedMark = stored[floor]; + if (storedMark) { + assertProvenanceCoversAuthority( + floor, + storedMark.authority, + stored.authorities[authorityName] + ); + } + } + for (const [floor, [parentName, childName]] of Object.entries( + REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES + ) as Array< + [ + keyof typeof REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES, + readonly [keyof RepositoryHighWater["authorities"], keyof RepositoryHighWater["authorities"]] + ] + >) { + const storedMark = stored[floor]; + if (storedMark) { + assertProvenanceCoversAuthority( + `${floor} parent`, + storedMark.parentAuthority, + stored.authorities[parentName] + ); + assertProvenanceCoversAuthority( + `${floor} child`, + storedMark.childAuthority, + stored.authorities[childName] + ); + } + } +} + +function mergeMetadataHighWater( + role: string, + prior: MetadataHighWater | undefined, + candidate: MetadataHighWater | undefined +): MetadataHighWater | undefined { + if (!prior) return candidate; + if (!candidate) return prior; + if (candidate.version < prior.version) return prior; + if (candidate.version === prior.version) { + if (candidate.sha256 !== prior.sha256) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `${role} metadata conflicts with an authenticated observation at the same version.` + ); + } + return { + ...prior, + authority: mergeAuthorityProvenance(prior.authority, candidate.authority) + }; + } + return candidate; +} + +function mergeDescriptorHighWater( + role: string, + prior: DescriptorHighWater | undefined, + candidate: DescriptorHighWater | undefined +): DescriptorHighWater | undefined { + if (!prior) return candidate; + if (!candidate) return prior; + if (candidate.version < prior.version) return prior; + if (candidate.version === prior.version) { + if (candidate.sha256 !== prior.sha256) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `${role} metadata pointer conflicts with an authenticated observation at the same version.` + ); + } + return { + ...prior, + parentAuthority: mergeAuthorityProvenance(prior.parentAuthority, candidate.parentAuthority), + childAuthority: mergeAuthorityProvenance(prior.childAuthority, candidate.childAuthority) + }; + } + return candidate; +} + +function mergeRootHistories( + left: readonly RootHighWater[], + right: readonly RootHighWater[] +): RootHighWater[] { + const byVersion = new Map(); + for (const mark of left) byVersion.set(mark.version, mark); + let sharesAnchor = false; + for (const mark of right) { + const existing = byVersion.get(mark.version); + if (existing) { + sharesAnchor = true; + if (existing.sha256 !== mark.sha256) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `Authenticated TUF root forks conflict at version ${mark.version}.` + ); + } + } else { + byVersion.set(mark.version, mark); + } + } + if (!sharesAnchor) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Authenticated TUF root histories do not share an exact trust anchor." + ); + } + const merged = [...byVersion.values()].sort((a, b) => a.version - b.version); + for (let index = 1; index < merged.length; index += 1) { + if (merged[index].version !== merged[index - 1].version + 1) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Authenticated TUF root history is not sequential." + ); + } + } + return merged; +} + +function mergeRepositoryHighWater( + prior: RepositoryHighWater, + candidate: RepositoryHighWater +): RepositoryHighWater { + if (candidate.root.version < prior.root.version) { + return mergeRepositoryHighWater(candidate, prior); + } + if (candidate.root.version === prior.root.version) { + if ( + !sameCanonicalValue(candidate.root, prior.root) || + !sameCanonicalValue(candidate.authorities, prior.authorities) + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Authenticated TUF root state conflicts at the same version." + ); + } + } + + const rootAdvanced = candidate.root.version > prior.root.version; + const rootHistory = mergeRootHistories(prior.rootHistory, candidate.rootHistory); + const merged: RepositoryHighWater = { + root: rootAdvanced ? candidate.root : prior.root, + rootHistory, + authorities: rootAdvanced ? candidate.authorities : prior.authorities, + authorityHistory: rootAdvanced + ? advanceAuthorityHistory(prior, candidate) + : mergeAuthorityHistories(prior.authorityHistory, candidate.authorityHistory) + }; + for (const [floor, authorityName] of Object.entries(REPOSITORY_DIRECT_FLOOR_AUTHORITIES) as Array< + [keyof typeof REPOSITORY_DIRECT_FLOOR_AUTHORITIES, keyof RepositoryHighWater["authorities"]] + >) { + const priorMark = prior[floor]; + const baseline = !rootAdvanced + ? priorMark + : priorMark && + !safelyReplacesAuthority(priorMark.authority, candidate.authorities[authorityName]) + ? { + ...priorMark, + authority: unionAuthorityProvenance( + priorMark.authority, + candidate.authorities[authorityName] + ) + } + : undefined; + merged[floor] = mergeMetadataHighWater(floor, baseline, candidate[floor]); + } + for (const [floor, [parentName, childName]] of Object.entries( + REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES + ) as Array< + [ + keyof typeof REPOSITORY_DESCRIPTOR_FLOOR_AUTHORITIES, + readonly [keyof RepositoryHighWater["authorities"], keyof RepositoryHighWater["authorities"]] + ] + >) { + const priorMark = prior[floor]; + const baseline = !rootAdvanced + ? priorMark + : priorMark && + !safelyReplacesAuthority(priorMark.parentAuthority, candidate.authorities[parentName]) && + !safelyReplacesAuthority(priorMark.childAuthority, candidate.authorities[childName]) + ? { + ...priorMark, + parentAuthority: unionAuthorityProvenance( + priorMark.parentAuthority, + candidate.authorities[parentName] + ), + childAuthority: unionAuthorityProvenance( + priorMark.childAuthority, + candidate.authorities[childName] + ) + } + : undefined; + merged[floor] = mergeDescriptorHighWater(floor, baseline, candidate[floor]); + } + return merged; +} + +function mergeChannelHighWater( + environment: AttestationChannel, + prior: ChannelHighWater | undefined, + candidate: ChannelHighWater | undefined +): ChannelHighWater | undefined { + if (!prior) return candidate; + if (!candidate) return prior; + if (candidate.sequence < prior.sequence) return prior; + if (candidate.sequence === prior.sequence) { + if (candidate.policyId !== prior.policyId) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `${environment} channel changed without incrementing sequence ${candidate.sequence}.` + ); + } + return { + ...prior, + authority: mergeAuthorityProvenance(prior.authority, candidate.authority) + }; + } + return candidate; +} + +function mergeSecurityHighWaters(states: readonly SecurityHighWater[]): SecurityHighWater { + if (states.length === 0) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "No authenticated state was available to establish a trust high-water mark." + ); + } + const ordered = [...states].sort( + (left, right) => left.repository.root.version - right.repository.root.version + ); + let merged: SecurityHighWater = { + repository: ordered[0].repository, + channels: { ...ordered[0].channels } + }; + for (const candidate of ordered.slice(1)) { + const rootAdvanced = candidate.repository.root.version > merged.repository.root.version; + const channels: ChannelHighWaters = { ...merged.channels }; + if (rootAdvanced) { + for (const environment of ["prod", "dev"] as const) { + const floor = channels[environment]; + if ( + floor && + safelyReplacesAuthority(floor.authority, candidate.repository.authorities.targets) + ) { + delete channels[environment]; + } else if (floor) { + channels[environment] = { + ...floor, + authority: unionAuthorityProvenance( + floor.authority, + candidate.repository.authorities.targets + ) + }; + } + } + } + merged = { + repository: mergeRepositoryHighWater(merged.repository, candidate.repository), + channels: { + ...channels, + ...Object.fromEntries( + (["prod", "dev"] as const) + .map((environment) => [ + environment, + mergeChannelHighWater( + environment, + channels[environment], + candidate.channels[environment] + ) + ]) + .filter((entry): entry is [AttestationChannel, ChannelHighWater] => Boolean(entry[1])) + ) + } + }; + } + return merged; +} + +function securityHighWaterFromGeneration(generation: VerifiedGeneration): SecurityHighWater { + return { + repository: generation.repositoryHighWater, + channels: { [generation.raw.environment]: generation.channelHighWater } + }; +} + +function securityHighWaterFromObservation(observation: VerifiedObservation): SecurityHighWater { + return { + repository: observation.repositoryHighWater, + channels: observation.channelHighWater + }; +} + +async function verifyRawGeneration( + rawValue: unknown, + bootstrap: RootEnvelope, + now: Date, + enforceExpiry = true, + allowDraft = false +): Promise { + const parsedRaw = (allowDraft ? AnyRawGenerationSchema : RawGenerationSchema).safeParse(rawValue); + if (!parsedRaw.success) { + throw new AttestationTrustError("TRUST_CACHE_INVALID", "Attestation policy cache is invalid.", { + cause: parsedRaw.error + }); + } + const raw = parsedRaw.data; + const { root, rootHistory, authorityHistory } = await restoreRootChain( + bootstrap, + raw.rootChain, + raw.trustedRootVersion + ); + if (enforceExpiry) assertUnexpired(root.signed.expires, now, "root"); + + const timestamp = parseJsonString(raw.timestamp, TimestampEnvelopeSchema, "cached timestamp"); + assertThreshold(timestamp, root.signed, "timestamp"); + if (enforceExpiry) assertUnexpired(timestamp.signed.expires, now, "timestamp"); + const timestampExpiry = Date.parse(timestamp.signed.expires); + if (timestampExpiry - now.getTime() > MAX_TIMESTAMP_VALIDITY_MS) { + throw new AttestationTrustError( + "TUF_EXPIRED", + "Timestamp metadata validity exceeds the SDK's 48-hour freshness window." + ); + } + + const snapshotBytes = new TextEncoder().encode(raw.snapshot); + await assertBytesMatch(snapshotBytes, timestamp.signed.meta["snapshot.json"], "cached snapshot"); + const snapshot = parseJson(snapshotBytes, SnapshotEnvelopeSchema, "cached snapshot"); + assertMetadataVersion( + snapshot.signed.version, + timestamp.signed.meta["snapshot.json"].version, + "snapshot" + ); + assertThreshold(snapshot, root.signed, "snapshot"); + if (enforceExpiry) assertUnexpired(snapshot.signed.expires, now, "snapshot"); + + const targetsBytes = new TextEncoder().encode(raw.targets); + await assertBytesMatch(targetsBytes, snapshot.signed.meta["targets.json"], "cached targets"); + const targets = parseJson(targetsBytes, TargetsEnvelopeSchema, "cached targets"); + assertMetadataVersion( + targets.signed.version, + snapshot.signed.meta["targets.json"].version, + "targets" + ); + assertThreshold(targets, root.signed, "targets"); + if (enforceExpiry) assertUnexpired(targets.signed.expires, now, "targets"); + const policy = await policyFromTargets(raw, root, timestamp, snapshot, targets); + const observedRepositoryHighWater = await repositoryHighWaterFromMetadata( + root, + rootHistory, + authorityHistory, + timestamp, + snapshot, + targets + ); + const observedChannelHighWater = channelHighWaterFromPolicy( + policy, + observedRepositoryHighWater.authorities.targets + ); + let repositoryHighWater = observedRepositoryHighWater; + let channelHighWater = observedChannelHighWater; + if (raw.version === 4) { + assertRepositoryProvenanceCoversAuthorities(raw.repositoryHighWater); + assertProvenanceCoversAuthority( + `${raw.environment} channel`, + raw.channelHighWater.authority, + raw.repositoryHighWater.authorities.targets + ); + const normalized = mergeSecurityHighWaters([ + { + repository: raw.repositoryHighWater, + channels: { [raw.environment]: raw.channelHighWater } + }, + { + repository: observedRepositoryHighWater, + channels: { [raw.environment]: observedChannelHighWater } + } + ]); + assertRepositoryHighWaterBoundToMetadata(normalized.repository, observedRepositoryHighWater); + const normalizedChannel = normalized.channels[raw.environment]; + if ( + !normalizedChannel || + normalizedChannel.sequence !== observedChannelHighWater.sequence || + normalizedChannel.policyId !== observedChannelHighWater.policyId + ) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The persisted channel high-water mark does not match its authenticated policy." + ); + } + repositoryHighWater = normalized.repository; + channelHighWater = normalizedChannel; + } + return { + raw, + root, + timestamp, + snapshot, + targets, + policy, + repositoryHighWater, + channelHighWater + }; +} + +async function verifyRawObservation( + rawValue: unknown, + bootstrap: RootEnvelope, + allowDraft = false +): Promise { + const parsed = (allowDraft ? AnyRawObservationSchema : RawObservationSchema).safeParse(rawValue); + if (!parsed.success) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Authenticated repository observation cache is invalid.", + { cause: parsed.error } + ); + } + const raw = parsed.data; + const { root, rootHistory, authorityHistory } = await restoreRootChain( + bootstrap, + raw.rootChain, + raw.trustedRootVersion + ); + let timestamp: TimestampEnvelope | undefined; + let snapshot: SnapshotEnvelope | undefined; + let targets: TargetsEnvelope | undefined; + if (raw.timestamp) { + timestamp = parseJsonString(raw.timestamp, TimestampEnvelopeSchema, "observed timestamp"); + assertThreshold(timestamp, root.signed, "timestamp"); + } + if (raw.snapshot && timestamp) { + const bytes = new TextEncoder().encode(raw.snapshot); + await assertBytesMatch(bytes, timestamp.signed.meta["snapshot.json"], "observed snapshot"); + snapshot = parseJson(bytes, SnapshotEnvelopeSchema, "observed snapshot"); + assertMetadataVersion( + snapshot.signed.version, + timestamp.signed.meta["snapshot.json"].version, + "snapshot" + ); + assertThreshold(snapshot, root.signed, "snapshot"); + } + if (raw.targets && snapshot) { + const bytes = new TextEncoder().encode(raw.targets); + await assertBytesMatch(bytes, snapshot.signed.meta["targets.json"], "observed targets"); + targets = parseJson(bytes, TargetsEnvelopeSchema, "observed targets"); + assertMetadataVersion( + targets.signed.version, + snapshot.signed.meta["targets.json"].version, + "targets" + ); + assertThreshold(targets, root.signed, "targets"); + } + const observedRepositoryHighWater = await repositoryHighWaterFromMetadata( + root, + rootHistory, + authorityHistory, + timestamp, + snapshot, + targets + ); + let repositoryHighWater = observedRepositoryHighWater; + let channelHighWater: ChannelHighWaters = {}; + if (raw.version === 3) { + assertRepositoryProvenanceCoversAuthorities(raw.repositoryHighWater); + for (const environment of ["prod", "dev"] as const) { + const floor = raw.channelHighWater[environment]; + if (floor) { + assertProvenanceCoversAuthority( + `${environment} channel`, + floor.authority, + raw.repositoryHighWater.authorities.targets + ); + } + } + const normalized = mergeSecurityHighWaters([ + { + repository: raw.repositoryHighWater, + channels: raw.channelHighWater + }, + { repository: observedRepositoryHighWater, channels: {} } + ]); + assertRepositoryHighWaterBoundToMetadata(normalized.repository, observedRepositoryHighWater); + repositoryHighWater = normalized.repository; + channelHighWater = normalized.channels; + } + return { + raw, + root, + timestamp, + snapshot, + targets, + repositoryHighWater, + channelHighWater + }; +} + +async function downloadTarget( + fetcher: typeof fetch, + targets: TargetsEnvelope, + path: string, + expectedSha256: string | undefined, + maxBytes: number, + description: string +): Promise { + const descriptor = targetDescriptor(targets, path); + if (descriptor.length > maxBytes) { + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} exceeds the ${maxBytes}-byte limit.` + ); + } + if (expectedSha256 !== undefined && descriptor.hashes.sha256 !== expectedSha256) { + throw new AttestationTrustError( + "TUF_TARGET_INTEGRITY", + `${description} digest does not match its channel reference.` + ); + } + const bytes = await fetchBytes( + fetcher, + targetUrl(path, descriptor.hashes.sha256), + maxBytes, + description + ); + if (bytes === null) throw new Error("unreachable"); + await assertBytesMatch(bytes, descriptor, description); + return bytes; +} + +async function downloadPolicyTargets( + fetcher: typeof fetch, + environment: AttestationChannel, + targets: TargetsEnvelope +): Promise> { + const targetBytes: Record = {}; + const channelPath = `channels/${environment}.json`; + const channelBytes = await downloadTarget( + fetcher, + targets, + channelPath, + undefined, + MAX_POLICY_TARGET_BYTES, + `${environment} channel` + ); + targetBytes[channelPath] = encodeBase64(channelBytes); + const channel = parseJson(channelBytes, ChannelSchema, `${environment} channel`); + if (channel.environment !== environment) { + throw new AttestationTrustError( + "POLICY_ENVIRONMENT_MISMATCH", + `The ${environment} channel contains ${channel.environment} policy.` + ); + } + if (channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json") { + throw new AttestationTrustError( + "POLICY_INVALID", + "Channel Sigstore trusted-root target path is not the fixed v1 path." + ); + } + for (const active of channel.active) { + const manifestVersion = releaseVersionFromTarget( + active.manifestTarget, + environment, + "manifest.json" + ); + const bundleVersion = releaseVersionFromTarget( + active.bundleTarget, + environment, + "manifest.sigstore.json" + ); + if (manifestVersion !== bundleVersion) { + throw new AttestationTrustError( + "POLICY_INVALID", + "Manifest and bundle targets identify different releases." + ); + } + } + + const referenced: Array<[string, string, number, string, boolean]> = [ + [ + channel.sigstoreTrustedRootTarget.path, + channel.sigstoreTrustedRootTarget.sha256, + MAX_TRUST_ROOT_BYTES, + "Sigstore trusted root", + true + ] + ]; + for (const release of channel.active) { + referenced.push( + [ + release.manifestTarget, + release.manifestSha256, + MAX_MANIFEST_BYTES, + "release manifest", + true + ], + [release.bundleTarget, release.bundleSha256, MAX_BUNDLE_BYTES, "Sigstore bundle", true] + ); + } + + for (const [path, digest, limit, description, cacheBytes] of referenced) { + if (targetBytes[path] !== undefined) { + throw new AttestationTrustError("POLICY_INVALID", `Policy references target ${path} twice.`); + } + const bytes = await downloadTarget(fetcher, targets, path, digest, limit, description); + if (cacheBytes) targetBytes[path] = encodeBase64(bytes); + } + return targetBytes; +} + +function sameSignedPayload(left: { signed: unknown }, right: { signed: unknown }): boolean { + return JSON.stringify(canonicalize(left.signed)) === JSON.stringify(canonicalize(right.signed)); +} + +function generationCacheTuple(generation: VerifiedGeneration): readonly number[] { + return [ + generation.root.signed.version, + generation.timestamp.signed.version, + generation.snapshot.signed.version, + generation.targets.signed.version, + generation.policy.sequence + ]; +} + +function assertEquivalentMetadata(left: VerifiedGeneration, right: VerifiedGeneration): void { + if ( + !sameSignedPayload(left.root, right.root) || + !sameSignedPayload(left.timestamp, right.timestamp) || + !sameSignedPayload(left.snapshot, right.snapshot) || + !sameSignedPayload(left.targets, right.targets) + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Two attestation repository generations conflict at the same metadata version." + ); + } +} + +function assertEquivalentGeneration(left: VerifiedGeneration, right: VerifiedGeneration): void { + const channelPath = `channels/${left.raw.environment}.json`; + assertEquivalentMetadata(left, right); + if (left.raw.targetBytes[channelPath] !== right.raw.targetBytes[channelPath]) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Two attestation policy generations conflict at the same version." + ); + } +} + +function newestMetadataGeneration( + left: VerifiedGeneration, + right: VerifiedGeneration +): VerifiedGeneration { + const repository = mergeSecurityHighWaters([ + { repository: left.repositoryHighWater, channels: {} }, + { repository: right.repositoryHighWater, channels: {} } + ]).repository; + const leftNewest = sameCanonicalValue(left.repositoryHighWater, repository); + const rightNewest = sameCanonicalValue(right.repositoryHighWater, repository); + if (leftNewest && rightNewest) { + assertEquivalentMetadata(left, right); + return left; + } + if (leftNewest) return left; + if (rightNewest) return right; + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Attestation repository metadata high-water marks conflict." + ); +} + +function newestGeneration(left: VerifiedGeneration, right: VerifiedGeneration): VerifiedGeneration { + if (left.raw.environment !== right.raw.environment) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Attestation policy generations from different channels cannot share a cache floor." + ); + } + const merged = mergeSecurityHighWaters([ + securityHighWaterFromGeneration(left), + securityHighWaterFromGeneration(right) + ]); + const environment = left.raw.environment; + const leftNewest = + sameCanonicalValue(left.repositoryHighWater, merged.repository) && + sameCanonicalValue(left.channelHighWater, merged.channels[environment]); + const rightNewest = + sameCanonicalValue(right.repositoryHighWater, merged.repository) && + sameCanonicalValue(right.channelHighWater, merged.channels[environment]); + if (leftNewest && rightNewest) { + assertEquivalentGeneration(left, right); + return left; + } + if (leftNewest) return left; + if (rightNewest) return right; + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Cached attestation policy high-water marks conflict." + ); +} + +function sameMetadataGeneration(left: VerifiedGeneration, right: VerifiedGeneration): boolean { + if (!sameCanonicalValue(left.repositoryHighWater, right.repositoryHighWater)) return false; + assertEquivalentMetadata(left, right); + return true; +} + +function assertObservationNotBehind( + candidate: VerifiedObservation, + observations: readonly VerifiedObservation[], + requireComplete = false +): void { + if (requireComplete && (!candidate.timestamp || !candidate.snapshot || !candidate.targets)) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Current trust state is not a complete authenticated repository observation." + ); + } + const merged = mergeSecurityHighWaters([ + ...observations.map(securityHighWaterFromObservation), + securityHighWaterFromObservation(candidate) + ]); + if (!sameCanonicalValue(candidate.repositoryHighWater, merged.repository)) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Current trust state does not cover the repository high-water journal." + ); + } + for (const environment of ["prod", "dev"] as const) { + if ( + (requireComplete || candidate.channelHighWater[environment]) && + !sameCanonicalValue(candidate.channelHighWater[environment], merged.channels[environment]) + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + `Current trust state does not cover the ${environment} channel high-water journal.` + ); + } + } +} + +function observationDraftFromGeneration(generation: VerifiedGeneration): RawObservationDraft { + return { + trustedRootVersion: generation.raw.trustedRootVersion, + rootChain: generation.raw.rootChain, + timestamp: generation.raw.timestamp, + snapshot: generation.raw.snapshot, + targets: generation.raw.targets + }; +} + +function decodeRawMetadata(bytes: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} + +function defaultStorage(): Storage | null { + try { + return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage; + } catch { + return null; + } +} + +type CrossContextLockManager = { + request(name: string, options: { mode: "exclusive" }, callback: () => Promise): Promise; +}; + +function browserLockManager(): CrossContextLockManager | null { + if (typeof navigator === "undefined") return null; + const locks = (navigator as Navigator & { locks?: CrossContextLockManager }).locks; + return locks && typeof locks.request === "function" ? locks : null; +} + +const inRealmStorageLocks = new WeakMap>(); + +async function withInRealmStorageLock(storage: Storage, action: () => Promise): Promise { + const previous = inRealmStorageLocks.get(storage) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.catch(() => undefined).then(() => gate); + inRealmStorageLocks.set(storage, tail); + await previous.catch(() => undefined); + try { + return await action(); + } finally { + release(); + if (inRealmStorageLocks.get(storage) === tail) inRealmStorageLocks.delete(storage); + } +} + +function requireActivePolicy(policy: VerifiedAttestationPolicy): VerifiedAttestationPolicy { + if (policy.releases.length === 0) { + throw new AttestationTrustError( + "POLICY_RELEASE_NOT_ACTIVE", + `The authenticated ${policy.environment} channel has no active enclave release.` + ); + } + return policy; +} + +export class AttestationTufClient { + private readonly fetcher: typeof fetch; + private readonly storage: Storage | null; + private readonly now: () => Date; + private readonly bootstrapValue: unknown; + private readonly browserLocks: CrossContextLockManager | null; + private readonly memory = new Map(); + private readonly refreshes = new Map>(); + private commitTail: Promise = Promise.resolve(); + + constructor(options: AttestationTufClientOptions = {}) { + this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis); + this.browserLocks = options.storage === undefined ? browserLockManager() : null; + this.storage = options.storage === undefined ? defaultStorage() : options.storage; + this.now = options.now ?? (() => new Date()); + this.bootstrapValue = options.bootstrap ?? embeddedBootstrapJson; + } + + refresh(environment: AttestationChannel): Promise { + const parsedEnvironment = EnvironmentSchema.parse(environment); + const pending = this.refreshes.get(parsedEnvironment); + if (pending) return pending; + + const refresh = this.refreshOnce(parsedEnvironment).finally(() => { + if (this.refreshes.get(parsedEnvironment) === refresh) { + this.refreshes.delete(parsedEnvironment); + } + }); + this.refreshes.set(parsedEnvironment, refresh); + return refresh; + } + + getMemoryPolicy(environment: AttestationChannel): VerifiedAttestationPolicy | undefined { + return this.memory.get(environment)?.policy; + } + + /** + * Revalidates a previously returned policy against the persistent journal + * without performing another network refresh. This closes the interval in + * which another browser context can commit a revocation while an attestation + * document is being verified. + */ + async assertPolicyCurrent(policy: VerifiedAttestationPolicy): Promise { + const environment = EnvironmentSchema.parse(policy.environment); + const bootstrap = rootFromBootstrap(this.bootstrapValue); + await verifyInitialRoot(bootstrap); + if (!this.storage) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Persistent browser storage is required for attestation authorization." + ); + } + this.assertNoLegacyCache(); + + const generations = await this.readAllVerifiedGenerations(bootstrap); + const matching = generations.filter( + (generation) => + generation.raw.environment === environment && sameCanonicalValue(generation.policy, policy) + ); + if (matching.length === 0) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "The supplied attestation policy is no longer present in authenticated local state." + ); + } + // Cleanup is best-effort, so an older immutable generation can legitimately + // remain beside a newer generation for the same public policy. Reconcile + // every match and use its authenticated newest state rather than depending + // on storage enumeration order. + const candidate = matching.slice(1).reduce(newestGeneration, matching[0]); + + const assertCandidateCoversCurrentState = async (): Promise => { + const currentGenerations = await this.readAllVerifiedGenerations(bootstrap); + const observations = await this.readVerifiedObservations(bootstrap); + const merged = mergeSecurityHighWaters([ + ...currentGenerations.map(securityHighWaterFromGeneration), + ...observations.map((entry) => securityHighWaterFromObservation(entry.verified)) + ]); + if ( + !sameCanonicalValue(merged.repository, candidate.repositoryHighWater) || + !sameCanonicalValue(merged.channels[environment], candidate.channelHighWater) + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "The supplied attestation policy is behind authenticated persistent state." + ); + } + }; + + await assertCandidateCoversCurrentState(); + await verifyRawGeneration(candidate.raw, bootstrap, this.currentTime(), true, false); + // Verification contains asynchronous digest/signature work. Re-read after + // it so an observation committed during that work cannot be missed. + await assertCandidateCoversCurrentState(); + } + + private cachePrefix(environment: AttestationChannel): string { + return `${CACHE_PREFIX}${environment}:`; + } + + private cacheKey(generation: VerifiedGeneration): string { + return `${this.cachePrefix(generation.raw.environment)}${generationCacheTuple(generation).join(".")}:${generation.policy.policyId}`; + } + + private currentTime(): Date { + const now = this.now(); + if (!Number.isFinite(now.getTime())) { + throw new AttestationTrustError("TUF_EXPIRED", "The local clock is invalid."); + } + return now; + } + + private assertNoLegacyCache(): void { + if (!this.storage) return; + const entries = stableStorageSnapshot( + this.storage, + LEGACY_CACHE_PREFIX, + MAX_STORED_OBSERVATIONS + MAX_STORED_GENERATIONS_PER_CHANNEL * 2, + "The legacy attestation trust cache" + ); + if (entries.length > 0) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "A pre-authority-history attestation trust cache cannot be migrated safely." + ); + } + } + + private readStorage( + environment: AttestationChannel, + failOnUnavailable = false + ): StoredGeneration[] { + if (!this.storage) return []; + const entries: StoredGeneration[] = []; + try { + for (const { key, value } of stableStorageSnapshot( + this.storage, + this.cachePrefix(environment), + MAX_STORED_GENERATIONS_PER_CHANNEL, + `The ${environment} attestation cache` + )) { + if (value.length > MAX_CACHE_JSON_CHARS) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `The ${environment} attestation policy cache exceeds its size limit.` + ); + } + try { + entries.push({ key, raw: JSON.parse(value) as unknown }); + } catch (error) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + `The ${environment} attestation policy cache is corrupt.`, + { cause: error } + ); + } + } + } catch (error) { + if (error instanceof AttestationTrustError) throw error; + if (failOnUnavailable) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The persistent attestation trust cache could not be read safely.", + { cause: error } + ); + } + return []; + } + return entries; + } + + private readObservationStorage(failOnUnavailable = false): StoredGeneration[] { + if (!this.storage) return []; + const entries: StoredGeneration[] = []; + try { + for (const { key, value } of stableStorageSnapshot( + this.storage, + OBSERVATION_PREFIX, + MAX_STORED_OBSERVATIONS, + "The attestation repository observation journal" + )) { + if (value.length > MAX_CACHE_JSON_CHARS) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "An attestation repository observation exceeds its size limit." + ); + } + entries.push({ key, raw: JSON.parse(value) as unknown }); + } + } catch (error) { + if (error instanceof AttestationTrustError) throw error; + if (failOnUnavailable) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The persistent attestation observation journal could not be read safely.", + { cause: error } + ); + } + return []; + } + return entries; + } + + private async readVerifiedObservations( + bootstrap: RootEnvelope + ): Promise> { + const observations: Array<{ stored: StoredGeneration; verified: VerifiedObservation }> = []; + for (const stored of this.readObservationStorage(true)) { + const parsed = RawObservationSchema.safeParse(stored.raw); + if (!parsed.success) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Authenticated repository observation cache is invalid.", + { cause: parsed.error } + ); + } + observations.push({ stored, verified: await verifyRawObservation(stored.raw, bootstrap) }); + } + return observations; + } + + private async readAllVerifiedGenerations(bootstrap: RootEnvelope): Promise { + const verified: VerifiedGeneration[] = []; + const now = this.currentTime(); + for (const environment of ["prod", "dev"] as const) { + for (const stored of this.readStorage(environment, true)) { + verified.push(await verifyRawGeneration(stored.raw, bootstrap, now, false, false)); + } + const memory = this.memory.get(environment); + if (memory) { + verified.push(await verifyRawGeneration(memory.raw, bootstrap, now, false, false)); + } + } + return verified; + } + + private async observationKey(raw: RawObservation | LegacyRawObservation): Promise { + return `${OBSERVATION_PREFIX}${await sha256(new TextEncoder().encode(JSON.stringify(raw)))}`; + } + + private async persistObservation( + draft: RawObservationDraft, + bootstrap: RootEnvelope + ): Promise { + if (!this.storage) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Persistent browser storage is required for attestation authorization." + ); + } + const observed = await verifyRawObservation({ version: 1, ...draft }, bootstrap, true); + const before = await this.readVerifiedObservations(bootstrap); + const generations = await this.readAllVerifiedGenerations(bootstrap); + const merged = mergeSecurityHighWaters([ + ...generations.map(securityHighWaterFromGeneration), + ...before.map((entry) => securityHighWaterFromObservation(entry.verified)), + securityHighWaterFromObservation(observed) + ]); + if ( + merged.repository.root.version !== observed.root.signed.version || + !sameCanonicalValue(merged.repository.root, observed.repositoryHighWater.root) + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "A newer authenticated root was committed concurrently." + ); + } + try { + assertRepositoryHighWaterBoundToMetadata(merged.repository, observed.repositoryHighWater); + } catch (error) { + if (error instanceof AttestationTrustError && error.code === "TRUST_CACHE_INVALID") { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "Authenticated repository metadata is behind the persisted authority-scoped floor.", + { cause: error } + ); + } + throw error; + } + const raw: RawObservation = { + version: 3, + ...draft, + repositoryHighWater: merged.repository, + channelHighWater: merged.channels + }; + const verified = await verifyRawObservation(raw, bootstrap); + const serialized = JSON.stringify(raw); + if (serialized.length > MAX_CACHE_JSON_CHARS) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Authenticated repository observation is too large to persist safely." + ); + } + const key = await this.observationKey(raw); + try { + this.storage.setItem(key, serialized); + } catch (error) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Authenticated repository observation could not be persisted safely.", + { cause: error } + ); + } + const after = await this.readVerifiedObservations(bootstrap); + const afterGenerations = await this.readAllVerifiedGenerations(bootstrap); + assertObservationNotBehind(verified, [ + ...after.map((entry) => entry.verified), + ...afterGenerations.map((generation) => ({ + raw: { + version: 3 as const, + ...observationDraftFromGeneration(generation), + repositoryHighWater: generation.repositoryHighWater, + channelHighWater: { [generation.raw.environment]: generation.channelHighWater } + }, + root: generation.root, + timestamp: generation.timestamp, + snapshot: generation.snapshot, + targets: generation.targets, + repositoryHighWater: generation.repositoryHighWater, + channelHighWater: { [generation.raw.environment]: generation.channelHighWater } + })) + ]); + return verified; + } + + private async compactObservations( + keep: VerifiedObservation, + bootstrap: RootEnvelope + ): Promise { + const entries = await this.readVerifiedObservations(bootstrap); + const generations = await this.readAllVerifiedGenerations(bootstrap); + assertObservationNotBehind( + keep, + [ + ...entries.map((entry) => entry.verified), + ...generations.map((generation) => ({ + raw: { + version: 3 as const, + ...observationDraftFromGeneration(generation), + repositoryHighWater: generation.repositoryHighWater, + channelHighWater: { [generation.raw.environment]: generation.channelHighWater } + }, + root: generation.root, + timestamp: generation.timestamp, + snapshot: generation.snapshot, + targets: generation.targets, + repositoryHighWater: generation.repositoryHighWater, + channelHighWater: { [generation.raw.environment]: generation.channelHighWater } + })) + ], + true + ); + const keepKey = await this.observationKey(keep.raw); + for (const entry of entries) { + if (entry.stored.key === keepKey) continue; + try { + this.storage?.removeItem(entry.stored.key); + } catch { + // Older immutable observations are safe to retain. + } + } + } + + private async readVerifiedCache( + environment: AttestationChannel, + bootstrap: RootEnvelope, + now: Date + ): Promise<{ + channel: VerifiedGeneration | undefined; + global: VerifiedGeneration | undefined; + usable: boolean; + }> { + const verifiedByChannel = new Map(); + for (const channel of ["prod", "dev"] as const) { + let storedGeneration: VerifiedGeneration | undefined; + for (const stored of this.readStorage(channel, true)) { + const parsed = RawGenerationSchema.safeParse(stored.raw); + if (!parsed.success) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Attestation policy cache is invalid.", + { cause: parsed.error } + ); + } + const verified = await verifyRawGeneration(stored.raw, bootstrap, now, false, false); + storedGeneration = storedGeneration + ? newestGeneration(storedGeneration, verified) + : verified; + } + const memory = this.memory.get(channel); + const memoryGeneration = memory + ? await verifyRawGeneration(memory.raw, bootstrap, now, false, false) + : undefined; + const verified = + storedGeneration && memoryGeneration + ? newestGeneration(storedGeneration, memoryGeneration) + : (storedGeneration ?? memoryGeneration); + if (!verified) continue; + if (verified.raw.environment !== channel) { + throw new AttestationTrustError( + "POLICY_ENVIRONMENT_MISMATCH", + "Cached attestation policy belongs to another channel." + ); + } + this.memory.set(channel, verified); + verifiedByChannel.set(channel, verified); + } + + const channel = verifiedByChannel.get(environment); + let global: VerifiedGeneration | undefined; + for (const generation of verifiedByChannel.values()) { + global = global ? newestMetadataGeneration(global, generation) : generation; + } + const usable = Boolean( + channel && + global && + sameMetadataGeneration(channel, global) && + (await this.isUsableCache(channel.raw, bootstrap, now)) + ); + return { + channel, + global, + usable + }; + } + + private async isUsableCache( + raw: RawGeneration | LegacyRawGeneration, + bootstrap: RootEnvelope, + now: Date + ): Promise { + try { + await verifyRawGeneration(raw, bootstrap, now, true, false); + return true; + } catch (error) { + if (error instanceof AttestationTrustError && error.code === "TUF_EXPIRED") return false; + throw error; + } + } + + private async finalizeGeneration( + generation: VerifiedGeneration, + bootstrap: RootEnvelope, + now: Date + ): Promise { + const observations = await this.readVerifiedObservations(bootstrap); + const generations = await this.readAllVerifiedGenerations(bootstrap); + const merged = mergeSecurityHighWaters([ + ...generations.map(securityHighWaterFromGeneration), + ...observations.map((entry) => securityHighWaterFromObservation(entry.verified)), + securityHighWaterFromGeneration(generation) + ]); + const environment = generation.raw.environment; + try { + assertRepositoryHighWaterBoundToMetadata(merged.repository, generation.repositoryHighWater); + } catch (error) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "The candidate policy is behind the authenticated repository floors.", + { cause: error } + ); + } + const mergedChannel = merged.channels[environment]; + if ( + !mergedChannel || + mergedChannel.sequence !== generation.channelHighWater.sequence || + mergedChannel.policyId !== generation.channelHighWater.policyId + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "The candidate policy is behind the authenticated channel floor." + ); + } + const raw: RawGeneration = { + version: 4, + trustedRootVersion: generation.raw.trustedRootVersion, + environment, + rootChain: generation.raw.rootChain, + timestamp: generation.raw.timestamp, + snapshot: generation.raw.snapshot, + targets: generation.raw.targets, + targetBytes: generation.raw.targetBytes, + repositoryHighWater: merged.repository, + channelHighWater: mergedChannel + }; + return await verifyRawGeneration(raw, bootstrap, now, true, false); + } + + private async commit( + generation: VerifiedGeneration, + bootstrap: RootEnvelope, + now: Date + ): Promise { + const serialized = JSON.stringify(generation.raw); + if (serialized.length > MAX_CACHE_JSON_CHARS) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The verified attestation policy is too large to persist safely." + ); + } + + const previous = this.commitTail; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + this.commitTail = previous.catch(() => undefined).then(() => gate); + await previous.catch(() => undefined); + + const commitLocked = async (): Promise => { + const existing: VerifiedGeneration[] = []; + const storedForEnvironment: StoredGeneration[] = []; + for (const channel of ["prod", "dev"] as const) { + const memory = this.memory.get(channel); + if (memory) { + existing.push(await verifyRawGeneration(memory.raw, bootstrap, now, false, false)); + } + for (const stored of this.readStorage(channel, true)) { + existing.push(await verifyRawGeneration(stored.raw, bootstrap, now, false, false)); + if (channel === generation.raw.environment) storedForEnvironment.push(stored); + } + } + + const assertCandidateIsNewest = (current: VerifiedGeneration): void => { + const newest = + current.raw.environment === generation.raw.environment + ? newestGeneration(generation, current) + : newestMetadataGeneration(generation, current); + if (newest !== generation) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "A newer attestation repository generation was committed concurrently." + ); + } + }; + for (const current of existing) assertCandidateIsNewest(current); + + const assertCandidateCoversJournal = async (): Promise => { + const observations = await this.readVerifiedObservations(bootstrap); + const merged = mergeSecurityHighWaters([ + ...existing.map(securityHighWaterFromGeneration), + ...observations.map((entry) => securityHighWaterFromObservation(entry.verified)), + securityHighWaterFromGeneration(generation) + ]); + if ( + !sameCanonicalValue(merged.repository, generation.repositoryHighWater) || + !sameCanonicalValue( + merged.channels[generation.raw.environment], + generation.channelHighWater + ) + ) { + throw new AttestationTrustError( + "TUF_ROLLBACK", + "A newer repository or channel floor was observed concurrently." + ); + } + }; + await assertCandidateCoversJournal(); + + await verifyRawGeneration(generation.raw, bootstrap, this.currentTime(), true, false); + + if (this.storage) { + const key = this.cacheKey(generation); + try { + // Generation keys are immutable. A stale tab can add an older key but + // can never overwrite or erase a newer generation it did not observe. + this.storage.setItem(key, serialized); + } catch (error) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "The verified attestation policy could not be persisted safely.", + { cause: error } + ); + } + + // Web Locks are not available in every browser. Re-read after the + // immutable write so a newer generation committed during asynchronous + // verification prevents this stale refresh from authorizing a session. + for (const channel of ["prod", "dev"] as const) { + for (const stored of this.readStorage(channel, true)) { + assertCandidateIsNewest( + await verifyRawGeneration(stored.raw, bootstrap, this.currentTime(), false, false) + ); + } + } + await assertCandidateCoversJournal(); + for (const stored of storedForEnvironment) { + if (stored.key === key) continue; + try { + this.storage.removeItem(stored.key); + } catch { + // Leaving an older immutable entry is safe; readers select the newest. + } + } + } + }; + + try { + if (this.storage && this.browserLocks) { + await this.browserLocks.request( + "opensecret:attestation-tuf:repository", + { mode: "exclusive" }, + commitLocked + ); + } else if (this.storage) { + await withInRealmStorageLock(this.storage, commitLocked); + } else { + await commitLocked(); + } + } finally { + release(); + } + } + + private async refreshOnce(environment: AttestationChannel): Promise { + const now = this.currentTime(); + const bootstrap = rootFromBootstrap(this.bootstrapValue); + await verifyInitialRoot(bootstrap); + if (!this.storage) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Persistent browser storage is required for attestation authorization." + ); + } + this.assertNoLegacyCache(); + const cachedState = await this.readVerifiedCache(environment, bootstrap, now); + const cached = cachedState.channel; + const globalCached = cachedState.global; + const storedObservations = await this.readVerifiedObservations(bootstrap); + const storedGenerations = await this.readAllVerifiedGenerations(bootstrap); + + try { + let startingRoot = globalCached?.root ?? bootstrap; + let startingChain = globalCached?.raw.rootChain ?? []; + const startingPoints = [ + ...storedGenerations.map((generation) => ({ + root: generation.root, + rootChain: generation.raw.rootChain, + security: securityHighWaterFromGeneration(generation) + })), + ...storedObservations.map(({ verified }) => ({ + root: verified.root, + rootChain: verified.raw.rootChain, + security: securityHighWaterFromObservation(verified) + })) + ]; + if (startingPoints.length > 0) { + // Validate the complete authenticated journal before selecting a root + // or making a request. In particular, an equal-version root fork must + // not be hidden by a colliding generation-cache key. + const startupFloor = mergeSecurityHighWaters(startingPoints.map((point) => point.security)); + const matching = startingPoints + .filter((point) => + sameCanonicalValue(point.security.repository.root, startupFloor.repository.root) + ) + .sort((left, right) => right.rootChain.length - left.rootChain.length)[0]; + if (!matching) { + throw new AttestationTrustError( + "TRUST_CACHE_INVALID", + "Authenticated local state does not contain its root high-water envelope." + ); + } + startingRoot = matching.root; + startingChain = matching.rootChain; + } + let onlineRaw: RawObservationDraft = { + trustedRootVersion: bootstrap.signed.version, + rootChain: [...startingChain] + }; + const { root, rootChain } = await refreshRootChain( + this.fetcher, + startingRoot, + startingChain, + maximumRootVersionForBootstrap(bootstrap.signed.version), + async (_authenticatedRoot, authenticatedChain) => { + onlineRaw = { ...onlineRaw, rootChain: [...authenticatedChain] }; + await this.persistObservation(onlineRaw, bootstrap); + } + ); + assertUnexpired(root.signed.expires, now, "root"); + onlineRaw = { ...onlineRaw, rootChain }; + await this.persistObservation(onlineRaw, bootstrap); + + const timestampBytes = await fetchBytes( + this.fetcher, + metadataUrl("timestamp.json"), + MAX_TIMESTAMP_BYTES, + "TUF timestamp" + ); + if (timestampBytes === null) throw new Error("unreachable"); + const timestamp = parseJson(timestampBytes, TimestampEnvelopeSchema, "TUF timestamp"); + assertThreshold(timestamp, root.signed, "timestamp"); + assertUnexpired(timestamp.signed.expires, now, "timestamp"); + if (Date.parse(timestamp.signed.expires) - now.getTime() > MAX_TIMESTAMP_VALIDITY_MS) { + throw new AttestationTrustError( + "TUF_EXPIRED", + "Timestamp metadata validity exceeds the SDK's 48-hour freshness window." + ); + } + onlineRaw = { ...onlineRaw, timestamp: decodeRawMetadata(timestampBytes) }; + await this.persistObservation(onlineRaw, bootstrap); + if ( + cached && + cachedState.usable && + globalCached && + sameMetadataGeneration(cached, globalCached) && + root.signed.version === globalCached.root.signed.version && + timestamp.signed.version === globalCached.timestamp.signed.version && + sameSignedPayload(timestamp, globalCached.timestamp) && + (await this.isUsableCache(cached.raw, bootstrap, this.currentTime())) + ) { + const complete = await this.persistObservation( + observationDraftFromGeneration(cached), + bootstrap + ); + await this.compactObservations(complete, bootstrap); + return requireActivePolicy(cached.policy); + } + + const snapshotDescriptor = timestamp.signed.meta["snapshot.json"]; + const snapshotBytes = await fetchBytes( + this.fetcher, + metadataUrl(`${snapshotDescriptor.version}.snapshot.json`), + MAX_SNAPSHOT_BYTES, + "TUF snapshot" + ); + if (snapshotBytes === null) throw new Error("unreachable"); + await assertBytesMatch(snapshotBytes, snapshotDescriptor, "TUF snapshot"); + const snapshot = parseJson(snapshotBytes, SnapshotEnvelopeSchema, "TUF snapshot"); + assertMetadataVersion(snapshot.signed.version, snapshotDescriptor.version, "snapshot"); + assertThreshold(snapshot, root.signed, "snapshot"); + assertUnexpired(snapshot.signed.expires, now, "snapshot"); + onlineRaw = { ...onlineRaw, snapshot: decodeRawMetadata(snapshotBytes) }; + await this.persistObservation(onlineRaw, bootstrap); + + const targetsDescriptor = snapshot.signed.meta["targets.json"]; + const targetsBytes = await fetchBytes( + this.fetcher, + metadataUrl(`${targetsDescriptor.version}.targets.json`), + MAX_TARGETS_BYTES, + "TUF targets" + ); + if (targetsBytes === null) throw new Error("unreachable"); + await assertBytesMatch(targetsBytes, targetsDescriptor, "TUF targets"); + const targets = parseJson(targetsBytes, TargetsEnvelopeSchema, "TUF targets"); + assertMetadataVersion(targets.signed.version, targetsDescriptor.version, "targets"); + assertThreshold(targets, root.signed, "targets"); + assertUnexpired(targets.signed.expires, now, "targets"); + onlineRaw = { ...onlineRaw, targets: decodeRawMetadata(targetsBytes) }; + await this.persistObservation(onlineRaw, bootstrap); + + const targetBytes = await downloadPolicyTargets(this.fetcher, environment, targets); + const raw: LegacyRawGeneration = { + version: 2, + trustedRootVersion: bootstrap.signed.version, + environment, + rootChain, + timestamp: decodeRawMetadata(timestampBytes), + snapshot: decodeRawMetadata(snapshotBytes), + targets: decodeRawMetadata(targetsBytes), + targetBytes + }; + const completionTime = this.currentTime(); + const draftCandidate = await verifyRawGeneration(raw, bootstrap, completionTime, true, true); + const candidate = await this.finalizeGeneration(draftCandidate, bootstrap, completionTime); + await this.commit(candidate, bootstrap, completionTime); + const current = await verifyRawGeneration( + candidate.raw, + bootstrap, + this.currentTime(), + true, + false + ); + const completeObservation = await this.persistObservation(onlineRaw, bootstrap); + await this.compactObservations(completeObservation, bootstrap); + this.memory.set(environment, current); + return requireActivePolicy(current.policy); + } catch (error) { + if (error instanceof TrustNetworkError) { + const fallback = await this.readVerifiedCache(environment, bootstrap, this.currentTime()); + if (fallback.channel && fallback.usable) { + const complete = await this.persistObservation( + observationDraftFromGeneration(fallback.channel), + bootstrap + ); + await this.compactObservations(complete, bootstrap); + return requireActivePolicy(fallback.channel.policy); + } + } + throw error; + } + } +} + +// This is a release invariant, not a cache migration mechanism. Supported +// clients stay anchored at root v1 and authenticate every numbered remote root. +assertOfficialEmbeddedBootstrap(embeddedBootstrapJson); +const defaultClient = new AttestationTufClient(); + +export function refreshAttestationPolicy( + environment: AttestationChannel +): Promise { + return defaultClient.refresh(environment); +} + +export function getCachedAttestationPolicy( + environment: AttestationChannel +): VerifiedAttestationPolicy | undefined { + return defaultClient.getMemoryPolicy(environment); +} + +export function assertAttestationPolicyCurrent(policy: VerifiedAttestationPolicy): Promise { + return defaultClient.assertPolicyCurrent(policy); +} + +/** @internal Exercises the official embedded-root release sentinel in tests. */ +export function assertOfficialEmbeddedBootstrapForTesting(bootstrap: unknown): void { + assertOfficialEmbeddedBootstrap(bootstrap); +} diff --git a/sdk/src/lib/developer.tsx b/sdk/src/lib/developer.tsx index 2e2484a9f..5ec7b1c1a 100644 --- a/sdk/src/lib/developer.tsx +++ b/sdk/src/lib/developer.tsx @@ -12,7 +12,7 @@ import { import type { AttestationDocument } from "./attestation"; import { PcrConfig } from "./pcr"; -const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "production" }; +const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "prod" }; import type { Organization, Project, diff --git a/sdk/src/lib/getAttestation.ts b/sdk/src/lib/getAttestation.ts index 2b57336e1..66e5ebec2 100644 --- a/sdk/src/lib/getAttestation.ts +++ b/sdk/src/lib/getAttestation.ts @@ -1,15 +1,18 @@ -import { verifyAttestation, isLocalDevelopmentApiUrl } from "./attestation"; +import { verifyAttestationDocument, isLocalDevelopmentApiUrl } from "./attestation"; import type { AttestationDocument } from "./attestation"; import { getApiUrl, keyExchange } from "./api"; import nacl from "tweetnacl"; import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305"; import { encode, decode } from "@stablelib/base64"; import { - requireTrustedPcr0, + requireTrustedPcrsAgainstSnapshot, + resolveAttestationEnvironment, + resolveTrustedPcrPolicy, serializePcrConfig, snapshotPcrConfig, - validatePcr0Hash, - type PcrConfig + type AttestationEnvironment, + type PcrConfig, + type TrustedEnclaveReleaseSnapshot } from "./pcr"; export interface Attestation { @@ -38,8 +41,9 @@ const SESSION_ID_PATTERN = /^[\x21-\x7e]+$/; /** @internal Exported for deterministic handshake tests, not from the package entry point. */ export interface GetAttestationDependencies { - verifyAttestation: typeof verifyAttestation; - validatePcr0Hash: typeof validatePcr0Hash; + verifyAttestation: typeof verifyAttestationDocument; + resolveTrustedPcrPolicy: typeof resolveTrustedPcrPolicy; + requireTrustedPcrsAgainstSnapshot: typeof requireTrustedPcrsAgainstSnapshot; keyExchange: typeof keyExchange; generateNaclKeyPair: () => NaclKeyPair; decryptSessionKey: ( @@ -78,8 +82,9 @@ function decryptSessionKey( } const defaultDependencies: GetAttestationDependencies = { - verifyAttestation, - validatePcr0Hash, + verifyAttestation: verifyAttestationDocument, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot, keyExchange, generateNaclKeyPair, decryptSessionKey, @@ -274,6 +279,9 @@ export async function getAttestationWithDependencies( const policy = snapshotPcrConfig(pcrConfig); const scope = await getAttestationScope(configuredApiUrl, policy); const localDevelopment = isLocalDevelopmentApiUrl(scope.apiUrl); + const expectedEnvironment: AttestationEnvironment | undefined = localDevelopment + ? undefined + : resolveAttestationEnvironment(scope.apiUrl, policy.environment); console.groupCollapsed("Attestation"); try { @@ -292,11 +300,20 @@ export async function getAttestationWithDependencies( } } + let trustedPolicy: TrustedEnclaveReleaseSnapshot | undefined; + if (!localDevelopment) { + // The backend's pending attestation secret is short-lived. Resolve TUF + // before requesting a nonce-bound document so repository refresh latency + // cannot consume that key-exchange window. + trustedPolicy = await dependencies.resolveTrustedPcrPolicy(expectedEnvironment!); + } + const attestationNonce = dependencies.randomUUID(); console.log("Generated attestation nonce:", attestationNonce); const document: AttestationDocument = await dependencies.verifyAttestation( attestationNonce, - scope.apiUrl + scope.apiUrl, + expectedEnvironment ); if (!document.public_key) { @@ -308,13 +325,17 @@ export async function getAttestationWithDependencies( verifiedPcr0 = "local-development"; console.warn("LOCAL DEVELOPMENT: PCR0 verification is bypassed for exact HTTP loopback."); } else { - const trustedPcr = await requireTrustedPcr0( + await dependencies.requireTrustedPcrsAgainstSnapshot( document.pcrs, - policy, - dependencies.validatePcr0Hash + expectedEnvironment!, + trustedPolicy! ); - verifiedPcr0 = trustedPcr.hash; - console.log("Attestation PCR0 trust verification succeeded."); + const pcr0 = document.pcrs.get(0); + if (!pcr0 || pcr0.length !== 48) { + throw new Error("Attestation document must contain a 48-byte PCR0 value."); + } + verifiedPcr0 = Array.from(pcr0, (byte) => byte.toString(16).padStart(2, "0")).join(""); + console.log("Attestation trusted-release PCR0/PCR1/PCR2 verification succeeded."); } const clientKeyPair = dependencies.generateNaclKeyPair(); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index bd18e8879..02b83d516 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -143,7 +143,23 @@ export type { } from "./developer"; export type { AttestationDocument } from "./attestation"; export type { ParsedAttestationView } from "./attestationForView"; -export type { PcrConfig, PcrEnvironment, Pcr0ValidationResult } from "./pcr"; +export { + getTrustedReleaseSnapshot, + getTrustedReleaseSnapshotId, + type AttestationEnvironment, + type PcrConfig, + type Pcr0ValidationResult, + type PcrValidationResult, + type TrustedEnclaveRelease, + type TrustedEnclaveReleaseSnapshot +} from "./pcr"; +export { + ATTESTATION_TUF_BASE_URL, + AttestationTrustError, + type NitroReleaseManifest, + type SigstoreEvidence, + type VerifiedAttestationPolicy +} from "./attestationTuf"; // Export crypto utilities // TODO: these can actually just be used internally by the password reset function diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index e5a23ca88..e6ff26945 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -14,7 +14,7 @@ import type { AttestationDocument } from "./attestation"; import type { LoginResponse, ThirdPartyTokenResponse, DocumentResponse } from "./api"; import { PcrConfig } from "./pcr"; -const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "production" }; +const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "prod" }; export type OpenSecretAuthState = { loading: boolean; diff --git a/sdk/src/lib/pcr.ts b/sdk/src/lib/pcr.ts index 98cc9c024..da07a6071 100644 --- a/sdk/src/lib/pcr.ts +++ b/sdk/src/lib/pcr.ts @@ -1,508 +1,283 @@ -/** - * Valid PCR0 values for production environments - */ -const DEFAULT_PCR0_VALUES = [ - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b", - "74ed417f88cb0ca76c4a3d10f278bd010f1d3f95eafb254d4732511bb50e404507a4049b779c5230137e4091a5582271", - "9043fcab93b972d3c14ad2dc8fa78ca7ad374fc937c02435681772a003f7a72876bc4d578089b5c4cf3fe9b480f1aabb", - "52c3595b151d93d8b159c257301bfd5aa6f49210de0c55a6cd6df5ebeee44e4206cab950500f5d188f7fa14e6d900b75", - "91cb67311e910cce68cd5b7d0de77aa40610d87c6681439b44c46c3ff786ae643956ab2c812478a1da8745b259f07a45", - "859065ac81b81d3735130ba08b8af72a7256b603fefb74faabae25ed28cca6edcaa7c10ea32b5948d675c18a9b0f2b1d", - "acd82a7d3943e23e95a9dc3ce0b0107ea358d6287f9e3afa245622f7c7e3e0a66142a928b6efcc02f594a95366d3a99d" -]; - -/** - * Valid PCR0 values for development environments - */ -const DEFAULT_PCR0_VALUES_DEV = [ - "62c0407056217a4c10764ed9045694c29fa93255d3cc04c2f989cdd9a1f8050c8b169714c71f1118ebce2fcc9951d1a9", - "cb95519905443f9f66f05f63c548b61ad1561a27fd5717b69285861aaea3c3063fe12a2571773b67fea3c6c11b4d8ec6", - "deb5895831b5e4286f5a2dcf5e9c27383821446f8df2b465f141d10743599be20ba3bb381ce063bf7139cc89f7f61d4c", - "70ba26c6af1ec3b57ce80e1adcc0ee96d70224d4c7a078f427895cdf68e1c30f09b5ac4c456588d872f3f21ff77c036b", - "669404ea71435b8f498b48db7816a5c2ab1d258b1a77685b11d84d15a73189504d79c4dee13a658de9f4a0cbfc39cfe8", - "a791bf92c25ffdfd372660e460a0e238c6778c090672df6509ae4bc065cf8668b6baac6b6a11d554af53ee0ff0172ad5", - "c4285443b87b9b12a6cea3bef1064ec060f652b235a297095975af8f134e5ed65f92d70d4616fdec80af9dff48bb9f35" -]; - -/** - * Public key used to verify PCR history signatures in SPKI DER format (base64-encoded) - */ -const PCR_VERIFICATION_PUBLIC_KEY_B64 = - "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEHiUY9kFWK1GqBGzczohhwEwElXzgWLDZa9R6wBx3JOBocgSt9+UIzZlJbPDjYeGBfDUXh7Z62BG2vVsh2NgclLB5S7A2ucBBtb1wd8vSQHP8jpdPhZX1slauPgbnROIP"; +import { z } from "zod"; +import { + assertAttestationPolicyCurrent, + getCachedAttestationPolicy, + refreshAttestationPolicy, + type AttestationChannel, + type TrustedTufRelease, + type VerifiedAttestationPolicy +} from "./attestationTuf"; + +const LOCAL_DEVELOPMENT_API_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); + +export const AttestationEnvironmentSchema = z.enum(["prod", "dev"]); +export type AttestationEnvironment = AttestationChannel; +export type TrustedEnclaveRelease = TrustedTufRelease; +export type TrustedEnclaveReleaseSnapshot = VerifiedAttestationPolicy; /** - * Remote PCR history URLs + * Attestation policy configuration. Official clients retrieve current policy + * from https://attestations.trymaple.ai/tuf. Raw allowlists and GitHub history + * URLs are not trust inputs. */ -const PCR_HISTORY_URLS = { - prod: "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrProdHistory.json", - dev: "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrDevHistory.json" +export type PcrConfig = { + environment?: AttestationEnvironment; + /** @deprecated Raw PCR overrides are not an authorization mechanism. */ + pcr0Values?: never; + /** @deprecated Raw PCR overrides are not an authorization mechanism. */ + pcr0DevValues?: never; + /** @deprecated Attestation policy has one fixed, authenticated TUF origin. */ + remoteAttestation?: never; + /** @deprecated Attestation policy has one fixed, authenticated TUF origin. */ + remoteAttestationUrls?: never; }; -const PCR0_HEX_LENGTH = 96; -const PCR0_HEX_PATTERN = /^[0-9a-f]{96}$/; -const PCR_HISTORY_TIMEOUT_MS = 5000; -const PCR_HISTORY_MAX_BYTES = 1024 * 1024; -const PCR_HISTORY_MAX_ENTRIES = 2048; -const PCR_SIGNATURE_BYTES = 96; - -export type Pcr0ValidationErrorCode = - | "PCR0_MISSING" - | "PCR0_INVALID_LENGTH" - | "PCR0_INVALID_FORMAT" - | "PCR0_ALL_ZERO" - | "PCR0_UNTRUSTED"; - -/** A hard attestation failure caused by an invalid or untrusted enclave identity. */ -export class Pcr0ValidationError extends Error { - readonly code: Pcr0ValidationErrorCode; - - constructor(code: Pcr0ValidationErrorCode, message: string) { - super(message); - this.name = "Pcr0ValidationError"; - this.code = code; - } +export function snapshotPcrConfig(config?: PcrConfig): PcrConfig { + return Object.freeze({ environment: config?.environment }); } -/** - * PCR history entry type - */ -export type PcrHistoryEntry = { - PCR0: string; - PCR1: string; - PCR2: string; - timestamp: number; - signature: string; -}; +export function serializePcrConfig(config?: PcrConfig): string { + const snapshot = snapshotPcrConfig(config); + return JSON.stringify({ + version: "attestation-tuf-v1", + environment: snapshot.environment ?? null + }); +} -/** - * Result of PCR0 validation - */ export type Pcr0ValidationResult = { - /** Whether the PCR0 hash matches a known good value */ isMatch: boolean; - /** Human-readable description of the validation result */ text: string; - /** Timestamp of when the PCR was verified (for remote attestation) */ + environment?: AttestationEnvironment; + releaseTag?: string; + releaseVersion?: string; + sourceCommit?: string; + sourceRef?: string; + artifactSha256?: string; + manifestSha256?: string; + bundleSha256?: string; + snapshotId: string; + channelSequence?: number; + /** Audit-only builder label from the authenticated release manifest. */ + builderId?: string; + sigstoreTrustedRootSha256?: string; + /** Locally verified Rekor transparency-log entry. */ + transparencyLog?: { logIndex: string; logId: string }; + /** Authenticated RFC3161 observer timestamp from the portable bundle. */ verifiedAt?: string; }; -/** The OpenSecret deployment environment whose PCR0 roots are trusted. */ -export type PcrEnvironment = "production" | "development"; +export type PcrValidationResult = Pcr0ValidationResult; -/** - * Configuration options for PCR validation - */ -export type PcrConfig = { - /** - * OpenSecret deployment environment to trust (defaults to production). - * Only this environment's embedded roots, additional roots, and signed - * history are considered during session establishment. - */ - environment?: PcrEnvironment; - /** - * Additional trusted PCR0 values for production environments. - * These and the SDK's built-in production roots are considered only when - * `environment` is `"production"`. - */ - pcr0Values?: string[]; - /** - * Additional trusted PCR0 values for development environments. - * These and the SDK's built-in development roots are considered only when - * `environment` is `"development"`. - */ - pcr0DevValues?: string[]; - /** - * Whether to consult pinned-key signed PCR history after local trust roots miss - * (defaults to true). This does not enable or disable Nitro attestation verification. - */ - remoteAttestation?: boolean; - /** Custom URLs for pinned-key signed PCR history. Only the selected environment is fetched. */ - remoteAttestationUrls?: { - /** URL for production PCR history */ - prod?: string; - /** URL for development PCR history */ - dev?: string; - }; -}; +const OFFICIAL_ENVIRONMENTS_BY_ORIGIN = new Map([ + ["https://api.opensecret.cloud", "prod"], + ["https://developer.opensecret.cloud", "prod"], + ["https://enclave.trymaple.ai", "prod"], + ["https://enclave.secretgpt.ai", "dev"] +]); -async function readBoundedUtf8Body(response: Response, maxBytes: number): Promise { - // Real fetch responses expose a byte stream. Some unit-test and legacy fetch - // implementations only expose text(), so keep a byte-counted fallback for them. - if (!response.body) { - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength > maxBytes) { - throw new Error("PCR history response is too large"); - } - return text; +export function normalizeApiOrigin(apiUrl: string): string { + const url = new URL(apiUrl); + if (url.username || url.password || url.search || url.hash) { + throw new Error("Attestation API URL must not include credentials, a query, or a fragment."); } - - const reader = response.body.getReader(); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let totalBytes = 0; - let text = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - totalBytes += value.byteLength; - if (totalBytes > maxBytes) { - try { - await reader.cancel("PCR history response is too large"); - } catch { - // Preserve the size-limit failure even if the stream cannot be cancelled. - } - throw new Error("PCR history response is too large"); - } - - text += decoder.decode(value, { stream: true }); - } - - text += decoder.decode(); - return text; - } finally { - reader.releaseLock(); + const isExactLoopback = LOCAL_DEVELOPMENT_API_HOSTS.has(url.hostname.toLowerCase()); + if (url.protocol !== "https:" && !(url.protocol === "http:" && isExactLoopback)) { + throw new Error("Attestation API URL must use HTTPS unless it is an exact loopback host."); } + return url.origin; } -type CanonicalPcrConfig = { - version: "pcr0-environment-v1"; - environment: PcrEnvironment; - verificationKey: string; - defaultPcr0Values: string[]; - pcr0Values: string[]; - remoteAttestation: boolean; - remoteAttestationUrl: string; -}; - -function normalizedValues(values: string[] | undefined): string[] { - return [...new Set((values || []).map((value) => value.trim().toLowerCase()))].sort(); -} - -function normalizePcrEnvironment(environment: unknown): PcrEnvironment { - if (environment === undefined || environment === "production") return "production"; - if (environment === "development") return "development"; - throw new TypeError('PCR environment must be either "production" or "development".'); +export function normalizeApiBaseUrl(apiUrl: string): string { + const origin = normalizeApiOrigin(apiUrl); + const url = new URL(apiUrl); + const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); + return `${origin}${pathname}`; } -/** - * Takes an immutable snapshot of a PCR policy before an asynchronous handshake. - * This prevents caller mutations from changing which policy is validated or cached. - */ -export function snapshotPcrConfig(config?: PcrConfig): PcrConfig { - return { - environment: normalizePcrEnvironment(config?.environment), - pcr0Values: normalizedValues(config?.pcr0Values), - pcr0DevValues: normalizedValues(config?.pcr0DevValues), - remoteAttestation: config?.remoteAttestation !== false, - remoteAttestationUrls: { - prod: config?.remoteAttestationUrls?.prod || PCR_HISTORY_URLS.prod, - dev: config?.remoteAttestationUrls?.dev || PCR_HISTORY_URLS.dev - } - }; -} - -/** Stable representation used to bind cached sessions to their trust policy. */ -export function serializePcrConfig(config?: PcrConfig): string { - const snapshot = snapshotPcrConfig(config); - const environment = snapshot.environment || "production"; - const development = environment === "development"; - const canonical: CanonicalPcrConfig = { - version: "pcr0-environment-v1", - environment, - verificationKey: PCR_VERIFICATION_PUBLIC_KEY_B64, - defaultPcr0Values: [...(development ? DEFAULT_PCR0_VALUES_DEV : DEFAULT_PCR0_VALUES)].sort(), - pcr0Values: development ? snapshot.pcr0DevValues || [] : snapshot.pcr0Values || [], - remoteAttestation: snapshot.remoteAttestation !== false, - remoteAttestationUrl: development - ? snapshot.remoteAttestationUrls?.dev || PCR_HISTORY_URLS.dev - : snapshot.remoteAttestationUrls?.prod || PCR_HISTORY_URLS.prod - }; - - return JSON.stringify(canonical); -} - -/** Converts the authenticated Nitro PCR0 value into its canonical SHA-384 hex form. */ -export function pcr0BytesToHex(pcr0: Uint8Array | undefined): string { - if (!pcr0) { - throw new Pcr0ValidationError("PCR0_MISSING", "Attestation document is missing PCR0."); - } - if (pcr0.length !== PCR0_HEX_LENGTH / 2) { - throw new Pcr0ValidationError( - "PCR0_INVALID_LENGTH", - "Attestation document contains an invalid PCR0 length." - ); +export function resolveAttestationEnvironment( + apiUrl: string, + explicitEnvironment?: AttestationEnvironment +): AttestationEnvironment { + if ( + explicitEnvironment !== undefined && + !AttestationEnvironmentSchema.safeParse(explicitEnvironment).success + ) { + throw new Error("Attestation environment must be exactly prod or dev."); } - if (pcr0.every((byte) => byte === 0)) { - throw new Pcr0ValidationError( - "PCR0_ALL_ZERO", - "Attestation document contains an all-zero PCR0." + const origin = normalizeApiOrigin(apiUrl); + const officialEnvironment = OFFICIAL_ENVIRONMENTS_BY_ORIGIN.get(origin); + if (officialEnvironment && explicitEnvironment && explicitEnvironment !== officialEnvironment) { + throw new Error( + `Attestation environment ${explicitEnvironment} is not allowed for official origin ${origin}.` ); } - - const hash = Array.from(pcr0, (byte) => byte.toString(16).padStart(2, "0")).join(""); - if (!PCR0_HEX_PATTERN.test(hash)) { - throw new Pcr0ValidationError( - "PCR0_INVALID_FORMAT", - "Attestation document contains an invalid PCR0." + const environment = officialEnvironment ?? explicitEnvironment; + if (!environment) { + throw new Error( + `Attestation environment must be configured explicitly for non-official origin ${origin}.` ); } - return hash; + return environment; } -/** - * Enforces PCR0 trust for the already Nitro-authenticated attestation document. - * Callers must run this before key exchange or session persistence. - */ -export async function requireTrustedPcr0( - pcrs: Map, - config?: PcrConfig, - validate: typeof validatePcr0Hash = validatePcr0Hash -): Promise<{ hash: string; validation: Pcr0ValidationResult }> { - const hash = pcr0BytesToHex(pcrs.get(0)); - const validation = await validate(hash, config); - if (!validation.isMatch) { - throw new Pcr0ValidationError( - "PCR0_UNTRUSTED", - "Attestation PCR0 is not in the configured trust roots." - ); - } - return { hash, validation }; +/** @deprecated Use the snapshotId returned by requireTrustedPcrs. */ +export function getTrustedReleaseSnapshotId(environment?: AttestationEnvironment): string { + if (environment) return getCachedAttestationPolicy(environment)?.policyId ?? "unavailable"; + return ( + getCachedAttestationPolicy("prod")?.policyId ?? + getCachedAttestationPolicy("dev")?.policyId ?? + "unavailable" + ); } -/** - * Imports the verification public key into the Web Crypto API - */ -async function importVerificationKey(): Promise { - try { - // Decode the base64 key to binary - const binaryKey = new Uint8Array( - atob(PCR_VERIFICATION_PUBLIC_KEY_B64) - .split("") - .map((c) => c.charCodeAt(0)) - ); - - // Import as SPKI format - return await crypto.subtle.importKey( - "spki", // The format: SubjectPublicKeyInfo - binaryKey, // Pass the Uint8Array directly, not .buffer - { - name: "ECDSA", // The algorithm - namedCurve: "P-384" // The curve (must be P-384 to match our backend) - }, - false, // Not extractable - ["verify"] // Only for verification - ); - } catch (error) { - console.error("Error importing verification key:", error); - throw new Error("Failed to import PCR verification key"); - } +/** @deprecated Current policy is loaded asynchronously by requireTrustedPcrs. */ +export function getTrustedReleaseSnapshot( + environment?: AttestationEnvironment +): TrustedEnclaveReleaseSnapshot { + const snapshot = environment + ? getCachedAttestationPolicy(environment) + : (getCachedAttestationPolicy("prod") ?? getCachedAttestationPolicy("dev")); + if (!snapshot) throw new Error("No verified attestation TUF policy is cached in memory."); + return snapshot; } -/** - * Fetches PCR history from repository - */ -async function fetchPcrHistory( - env: "prod" | "dev", - urls?: { prod?: string; dev?: string } -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), PCR_HISTORY_TIMEOUT_MS); - try { - const baseUrl = urls?.[env] || PCR_HISTORY_URLS[env]; - const parsedUrl = new URL(baseUrl); - if ( - (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") || - parsedUrl.username || - parsedUrl.password || - parsedUrl.hash || - baseUrl.length > 2048 - ) { - throw new Error("Invalid PCR history URL"); - } - - const response = await fetch(baseUrl, { - redirect: "error", - signal: controller.signal - }); - - if (!response.ok || response.redirected) { - throw new Error(`Failed to fetch PCR history: ${response.status}`); - } - - const contentLength = response.headers.get("content-length"); - if (contentLength && Number(contentLength) > PCR_HISTORY_MAX_BYTES) { - throw new Error("PCR history response is too large"); - } - - const text = await readBoundedUtf8Body(response, PCR_HISTORY_MAX_BYTES); +function pcrBytesToHex(value: Uint8Array | undefined): string | null { + if (!value || value.length !== 48) return null; + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} - const history: unknown = JSON.parse(text); - if ( - !Array.isArray(history) || - history.length === 0 || - history.length > PCR_HISTORY_MAX_ENTRIES || - !history.every(isValidPcrHistoryEntry) - ) { - throw new Error("PCR history response has an invalid schema"); - } - return history; - } catch (error) { - console.error("Error fetching PCR history:", error); - throw new Error("Failed to fetch PCR history"); - } finally { - clearTimeout(timeout); - } +function matchedReleaseResult( + release: TrustedEnclaveRelease, + snapshot: TrustedEnclaveReleaseSnapshot +): PcrValidationResult { + const { manifest, sigstore } = release; + return { + isMatch: true, + text: `PCR0/PCR1/PCR2 match TUF-authorized ${manifest.environment} release ${manifest.release.version}`, + environment: manifest.environment, + releaseTag: `v${manifest.release.version}`, + releaseVersion: manifest.release.version, + sourceCommit: manifest.source.revision.digest, + sourceRef: manifest.source.ref, + artifactSha256: manifest.artifact.digests.sha256, + manifestSha256: release.manifestSha256, + bundleSha256: sigstore.bundleSha256, + snapshotId: snapshot.policyId, + channelSequence: snapshot.sequence, + builderId: manifest.build.builderId, + sigstoreTrustedRootSha256: sigstore.trustedRootSha256, + transparencyLog: sigstore.transparencyLog, + verifiedAt: sigstore.observerTimestamp + }; } -function isValidPcrHistoryEntry(entry: unknown): entry is PcrHistoryEntry { - if (!entry || typeof entry !== "object") return false; - const candidate = entry as Partial; - if ( - typeof candidate.PCR0 !== "string" || - typeof candidate.PCR1 !== "string" || - typeof candidate.PCR2 !== "string" || - !PCR0_HEX_PATTERN.test(candidate.PCR0) || - !PCR0_HEX_PATTERN.test(candidate.PCR1) || - !PCR0_HEX_PATTERN.test(candidate.PCR2) || - typeof candidate.timestamp !== "number" || - !Number.isSafeInteger(candidate.timestamp) || - candidate.timestamp <= 0 || - typeof candidate.signature !== "string" || - candidate.signature.length > 256 - ) { - return false; +export function validatePcrsAgainstSnapshot( + pcrs: ReadonlyMap, + environment: AttestationEnvironment, + snapshot: TrustedEnclaveReleaseSnapshot | undefined = getCachedAttestationPolicy(environment) +): PcrValidationResult { + const snapshotId = snapshot?.policyId ?? "unavailable"; + if (!snapshot || snapshot.environment !== environment) { + return { + isMatch: false, + text: `No verified ${environment} attestation policy is available`, + environment, + snapshotId + }; } - - try { - return atob(candidate.signature).length === PCR_SIGNATURE_BYTES; - } catch { - return false; + const actualPcrs = { + "0": pcrBytesToHex(pcrs.get(0)), + "1": pcrBytesToHex(pcrs.get(1)), + "2": pcrBytesToHex(pcrs.get(2)) + }; + if (!actualPcrs["0"] || !actualPcrs["1"] || !actualPcrs["2"]) { + return { + isMatch: false, + text: "Attestation document must contain 48-byte PCR0, PCR1, and PCR2 values", + environment, + snapshotId + }; } -} - -/** - * Verifies a PCR0 signature - */ -async function verifyPcr0Signature( - pcr0: string, - signatureBase64: string, - publicKey: CryptoKey -): Promise { - try { - // Convert PCR0 string to binary - const encoder = new TextEncoder(); - const pcr0Binary = encoder.encode(pcr0); - - // Convert signature from base64 to binary - const signatureBinary = new Uint8Array( - atob(signatureBase64) - .split("") - .map((c) => c.charCodeAt(0)) + const match = snapshot.releases.find((release) => { + const expected = release.manifest.measurements.pcrs; + return ( + release.manifest.environment === environment && + expected["0"] === actualPcrs["0"] && + expected["1"] === actualPcrs["1"] && + expected["2"] === actualPcrs["2"] ); - - // Verify using Web Crypto API - return await crypto.subtle.verify( - { - name: "ECDSA", - hash: { name: "SHA-384" } // Must match the hash used for signing - }, - publicKey, - signatureBinary, - pcr0Binary - ); - } catch (error) { - console.error("Signature verification error:", error); - return false; + }); + if (!match) { + return { + isMatch: false, + text: `PCR0/PCR1/PCR2 do not match one active ${environment} release`, + environment, + snapshotId, + channelSequence: snapshot.sequence + }; } + return matchedReleaseResult(match, snapshot); } -/** - * Validates a PCR0 against remote history - */ -async function validatePcrAgainstRemoteHistory( - pcr0: string, - env: "prod" | "dev", - urls?: { prod?: string; dev?: string } -): Promise { - try { - // Import the verification key - const publicKey = await importVerificationKey(); - - // Fetch the PCR history - const history = await fetchPcrHistory(env, urls); +export async function resolveTrustedPcrPolicy( + environment: AttestationEnvironment +): Promise { + return await refreshAttestationPolicy(environment); +} - // Find a matching entry in the history - for (const entry of history) { - // Only check if PCR0 matches - we don't care about PCR1 or PCR2 - if (entry.PCR0 === pcr0) { - // Verify the signature (only of PCR0) - const isValid = await verifyPcr0Signature(entry.PCR0, entry.signature, publicKey); - if (isValid) { - return { - isMatch: true, - text: "PCR0 matches remotely attested value", - verifiedAt: new Date(entry.timestamp * 1000).toLocaleString() - }; - } - } +export async function requireTrustedPcrsAgainstSnapshot( + pcrs: ReadonlyMap, + environment: AttestationEnvironment, + snapshot: TrustedEnclaveReleaseSnapshot, + now = new Date() +): Promise { + if (!Number.isFinite(now.getTime())) throw new Error("The local clock is invalid."); + for (const role of ["root", "timestamp", "snapshot", "targets"] as const) { + const expiry = Date.parse(snapshot.expires[role]); + if (!Number.isFinite(expiry) || expiry <= now.getTime()) { + throw new Error(`${role} metadata is expired.`); } - - // No valid match found - return null; - } catch (error) { - console.error("PCR remote validation error:", error); - // We return null for remote validation errors so we can fall back to local validation - return null; } + // A different browser context may have committed a newer/revoking policy + // while this attestation document was being verified. Recheck persistent + // authenticated state without another network refresh before authorizing it. + await assertAttestationPolicyCurrent(snapshot); + const result = validatePcrsAgainstSnapshot(pcrs, environment, snapshot); + if (!result.isMatch) throw new Error(result.text); + return result; } -/** - * Validates a PCR0 hash and returns information about the match - * @param hash - The PCR0 hash to validate - * @param config - Optional configuration with custom PCR0 values - * @returns Object containing match status and descriptive text - */ +export async function requireTrustedPcrs( + pcrs: ReadonlyMap, + environment: AttestationEnvironment +): Promise { + const snapshot = await resolveTrustedPcrPolicy(environment); + return await requireTrustedPcrsAgainstSnapshot(pcrs, environment, snapshot); +} + +/** Display-only helper. PCR0 alone never authorizes key exchange. */ export async function validatePcr0Hash( hash: string, config?: PcrConfig ): Promise { - const normalizedHash = hash.trim().toLowerCase(); - const snapshot = snapshotPcrConfig(config); - const development = snapshot.environment === "development"; - const validPcr0Values = development - ? [...(snapshot.pcr0DevValues || []), ...DEFAULT_PCR0_VALUES_DEV] - : [...(snapshot.pcr0Values || []), ...DEFAULT_PCR0_VALUES]; - - if (validPcr0Values.includes(normalizedHash)) { + const environment = config?.environment; + if (!environment) { return { - isMatch: true, - text: development ? "PCR0 matches development enclave" : "PCR0 matches a known good value" + isMatch: false, + text: "An attestation environment is required; full PCR0/PCR1/PCR2 verification is required", + snapshotId: "unavailable" }; } - - // If remote attestation is enabled (default is true), check against remote PCR history - const remoteAttestationEnabled = snapshot.remoteAttestation !== false; - - if (remoteAttestationEnabled) { - try { - const remoteResult = await validatePcrAgainstRemoteHistory( - normalizedHash, - development ? "dev" : "prod", - snapshot.remoteAttestationUrls - ); - - if (remoteResult) return remoteResult; - } catch (error) { - console.error("Error during remote PCR validation:", error); - // We continue with default behavior if remote validation fails - } - } - + const snapshot = await refreshAttestationPolicy(environment); + const match = snapshot.releases.find( + (release) => release.manifest.measurements.pcrs["0"] === hash + ); + if (match) return matchedReleaseResult(match, snapshot); return { isMatch: false, - text: "PCR0 does not match a known good value" + text: "PCR0 does not match an active release; full PCR0/PCR1/PCR2 verification is required", + environment, + snapshotId: snapshot.policyId, + channelSequence: snapshot.sequence }; } diff --git a/sdk/src/lib/sigstoreBrowser.ts b/sdk/src/lib/sigstoreBrowser.ts new file mode 100644 index 000000000..7f6f65467 --- /dev/null +++ b/sdk/src/lib/sigstoreBrowser.ts @@ -0,0 +1,444 @@ +import { + CertificateChainVerifier, + SigstoreVerifier, + X509Certificate, + verifyBundleTimestamp, + type SigstoreBundle, + type TrustedRoot, + type VerificationPolicy +} from "@freedomofpress/sigstore-browser"; +import { decode as decodeBase64, encode as encodeBase64 } from "@stablelib/base64"; +import { z } from "zod"; + +const BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json"; +const TRUSTED_ROOT_MEDIA_TYPE = "application/vnd.dev.sigstore.trustedroot+json;version=0.1"; +const MAX_MANIFEST_BYTES = 128 * 1024; +const MAX_BUNDLE_BYTES = 2 * 1024 * 1024; +const MAX_TRUSTED_ROOT_BYTES = 512 * 1024; +const MAX_CERTIFICATE_BYTES = 64 * 1024; +const MAX_PUBLIC_KEY_BYTES = 16 * 1024; +const MAX_SIGNATURE_BYTES = 16 * 1024; +const MAX_TIMESTAMP_BYTES = 256 * 1024; +const MAX_REKOR_BODY_BYTES = 1024 * 1024; +const MAX_CHECKPOINT_CHARS = 64 * 1024; +const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +function strictBase64(maxBytes: number, exactBytes?: number): z.ZodType { + const maxChars = Math.ceil((maxBytes * 4) / 3) + 4; + return z + .string() + .min(4) + .max(maxChars) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) + .superRefine((value, context) => { + try { + const decoded = decodeBase64(value); + if (decoded.length === 0 || decoded.length > maxBytes) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "base64 value is too large" }); + return; + } + if (exactBytes !== undefined && decoded.length !== exactBytes) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `base64 value must decode to ${exactBytes} bytes` + }); + } + if (encodeBase64(decoded) !== value) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "base64 value is not canonically encoded" + }); + } + } catch { + context.addIssue({ code: z.ZodIssueCode.custom, message: "invalid base64 value" }); + } + }); +} + +const DecimalIntegerSchema = z + .string() + .regex(/^(0|[1-9][0-9]{0,15})$/) + .refine((value) => BigInt(value) <= MAX_SAFE_INTEGER_BIGINT, "integer exceeds the safe range"); +const PositiveDecimalIntegerSchema = DecimalIntegerSchema.refine( + (value) => value !== "0", + "integer must be positive" +); +const DateTimeSchema = z.string().max(64).datetime({ offset: true }); + +const CertificateSchema = z + .object({ + rawBytes: strictBase64(MAX_CERTIFICATE_BYTES) + }) + .strict(); + +const InclusionProofSchema = z + .object({ + logIndex: DecimalIntegerSchema, + rootHash: strictBase64(32, 32), + treeSize: PositiveDecimalIntegerSchema, + hashes: z.array(strictBase64(32, 32)).max(64), + checkpoint: z + .object({ + envelope: z.string().min(1).max(MAX_CHECKPOINT_CHARS) + }) + .strict() + }) + .strict() + .superRefine((proof, context) => { + if (BigInt(proof.logIndex) >= BigInt(proof.treeSize)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["logIndex"], + message: "inclusion-proof index must be smaller than its tree size" + }); + } + }); + +const TLogEntrySchema = z + .object({ + logIndex: DecimalIntegerSchema, + logId: z.object({ keyId: strictBase64(32, 32) }).strict(), + kindVersion: z + .object({ + kind: z.literal("hashedrekord"), + version: z.enum(["0.0.1", "0.0.2"]) + }) + .strict(), + integratedTime: z.union([DecimalIntegerSchema, z.literal(0)]).nullish(), + inclusionPromise: z + .object({ signedEntryTimestamp: strictBase64(MAX_SIGNATURE_BYTES) }) + .strict() + .optional(), + inclusionProof: InclusionProofSchema, + canonicalizedBody: strictBase64(MAX_REKOR_BODY_BYTES) + }) + .strict() + .superRefine((entry, context) => { + if (entry.kindVersion.version === "0.0.1") { + if (entry.integratedTime === null || entry.integratedTime === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["integratedTime"], + message: "Rekor v1 entries require an integrated time" + }); + } else if (entry.integratedTime === "0" || entry.integratedTime === 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["integratedTime"], + message: "Rekor v1 integrated time must be positive" + }); + } + if (entry.inclusionPromise === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["inclusionPromise"], + message: "Rekor v1 integrated time requires a signed entry timestamp" + }); + } + } else if ( + entry.integratedTime !== null && + entry.integratedTime !== undefined && + entry.integratedTime !== "0" && + entry.integratedTime !== 0 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["integratedTime"], + message: "Rekor v2 integrated time must be absent, null, or zero" + }); + } + }); + +const TimestampVerificationDataSchema = z + .object({ + rfc3161Timestamps: z + .array(z.object({ signedTimestamp: strictBase64(MAX_TIMESTAMP_BYTES) }).strict()) + .min(1) + .max(4) + }) + .strict(); + +const BundleSchema = z + .object({ + mediaType: z.literal(BUNDLE_MEDIA_TYPE), + verificationMaterial: z + .object({ + certificate: CertificateSchema, + tlogEntries: z.array(TLogEntrySchema).length(1), + timestampVerificationData: TimestampVerificationDataSchema + }) + .strict(), + messageSignature: z + .object({ + messageDigest: z + .object({ + algorithm: z.literal("SHA2_256"), + digest: strictBase64(32, 32) + }) + .strict(), + signature: strictBase64(MAX_SIGNATURE_BYTES) + }) + .strict() + }) + .strict(); + +const ValidForSchema = z + .object({ + start: DateTimeSchema, + end: DateTimeSchema.optional() + }) + .strict() + .superRefine((validFor, context) => { + if (validFor.end !== undefined && Date.parse(validFor.end) <= Date.parse(validFor.start)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["end"], + message: "validity end must be after its start" + }); + } + }); + +const SubjectSchema = z + .object({ + organization: z.string().min(1).max(512), + commonName: z.string().min(1).max(512) + }) + .strict(); + +const CertificateChainSchema = z + .object({ + certificates: z.array(CertificateSchema).min(1).max(8) + }) + .strict(); + +const LogSchema = z + .object({ + baseUrl: z + .string() + .url() + .max(2048) + .refine(isExactHttpsUrl, "transparency-log URL must be exact HTTPS"), + hashAlgorithm: z.literal("SHA2_256"), + publicKey: z + .object({ + rawBytes: strictBase64(MAX_PUBLIC_KEY_BYTES), + keyDetails: z.string().min(1).max(128), + validFor: ValidForSchema + }) + .strict(), + logId: z.object({ keyId: strictBase64(32, 32) }).strict() + }) + .strict(); + +const CertificateAuthoritySchema = z + .object({ + subject: SubjectSchema, + uri: z.string().url().max(2048).refine(isExactHttpsUrl, "Fulcio URL must be exact HTTPS"), + certChain: CertificateChainSchema, + validFor: ValidForSchema + }) + .strict(); + +const TimestampAuthoritySchema = z + .object({ + subject: SubjectSchema, + uri: z.string().url().max(2048).refine(isExactHttpsUrl, "TSA URL must be exact HTTPS"), + certChain: CertificateChainSchema, + validFor: ValidForSchema + }) + .strict(); + +const TrustedRootSchema = z + .object({ + mediaType: z.literal(TRUSTED_ROOT_MEDIA_TYPE), + tlogs: z.array(LogSchema).min(1).max(16), + certificateAuthorities: z.array(CertificateAuthoritySchema).min(1).max(16), + ctlogs: z.array(LogSchema).min(1).max(16), + timestampAuthorities: z.array(TimestampAuthoritySchema).min(1).max(16) + }) + .strict(); + +export type VerifiedSigstoreEvidence = { + logIndex: string; + logId: string; + observerTimestamp: string; +}; + +class ExactTufTargetPolicy implements VerificationPolicy { + verify(_certificate: X509Certificate): void { + // The application policy was already applied when Maple TUF selected the + // exact manifest, bundle, and trusted-root bytes. Fulcio still authenticates + // the ephemeral signing key, but signer claims are audit data, not a second + // repository/workflow/issuer/SAN authorization layer. + } +} + +function isExactHttpsUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +function parseStrictJson(bytes: Uint8Array, schema: z.ZodType, description: string): T { + let decoded: string; + try { + decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new Error(`${description} is not valid UTF-8.`, { cause: error }); + } + let value: unknown; + try { + value = JSON.parse(decoded) as unknown; + } catch (error) { + throw new Error(`${description} is not valid JSON.`, { cause: error }); + } + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new Error(`${description} does not match Maple's strict Sigstore profile.`, { + cause: parsed.error + }); + } + return parsed.data; +} + +function assertSize(bytes: Uint8Array, maximum: number, description: string): void { + if (bytes.length === 0 || bytes.length > maximum) { + throw new Error(`${description} exceeds Maple's ${maximum}-byte limit.`); + } +} + +function withinValidity( + timestamp: Date, + validFor: { start: string; end?: string | undefined } +): boolean { + const instant = timestamp.getTime(); + return ( + instant >= Date.parse(validFor.start) && + (validFor.end === undefined || instant <= Date.parse(validFor.end)) + ); +} + +async function assertFullCertificatePathAtObserverTime( + certificateBase64: string, + trustedRoot: z.infer, + observerTimes: readonly Date[] +): Promise { + const leaf = X509Certificate.parse(decodeBase64(certificateBase64)); + for (const observerTime of observerTimes) { + let verified = false; + let lastError: unknown; + for (const authority of trustedRoot.certificateAuthorities) { + if (!withinValidity(observerTime, authority.validFor)) continue; + try { + const trustedCerts = authority.certChain.certificates.map((certificate) => + X509Certificate.parse(decodeBase64(certificate.rawBytes)) + ); + await new CertificateChainVerifier({ + untrustedCert: leaf, + trustedCerts, + timestamp: observerTime + }).verify(); + verified = true; + break; + } catch (error) { + lastError = error; + } + } + if (!verified) { + throw new Error( + "Sigstore certificate chain was not valid at an authenticated observer timestamp.", + { cause: lastError } + ); + } + } +} + +/** + * Verifies one exact TUF-authorized manifest and its portable Sigstore bundle. + * + * TUF selects the bytes. This adapter deliberately does not authorize a + * repository, workflow, OIDC issuer, or SAN value: builder admission belongs + * to release promotion. It still verifies the Fulcio path and SCT, Rekor body, + * inclusion proof and checkpoint, RFC3161 timestamp, and blob signature. + */ +export async function verifyTufAuthorizedSigstoreBundle( + manifestBytes: Uint8Array, + bundleBytes: Uint8Array, + trustedRootBytes: Uint8Array +): Promise { + assertSize(manifestBytes, MAX_MANIFEST_BYTES, "Release manifest"); + assertSize(bundleBytes, MAX_BUNDLE_BYTES, "Sigstore bundle"); + assertSize(trustedRootBytes, MAX_TRUSTED_ROOT_BYTES, "Sigstore trusted root"); + + const bundle = parseStrictJson(bundleBytes, BundleSchema, "Sigstore bundle"); + const trustedRoot = parseStrictJson(trustedRootBytes, TrustedRootSchema, "Sigstore trusted root"); + const bundleLogId = bundle.verificationMaterial.tlogEntries[0].logId.keyId; + const matchingLogs = trustedRoot.tlogs.filter((log) => log.logId.keyId === bundleLogId); + if (matchingLogs.length !== 1) { + throw new Error("Sigstore bundle must identify exactly one TUF-authorized transparency log."); + } + // Upstream 0.1.14 retains only the first currently valid Rekor key when it + // loads a root. Selecting the already TUF-authenticated key by the bundle's + // one required log ID avoids making root array order an accidental pin. + const verificationRoot = { ...trustedRoot, tlogs: matchingLogs }; + const bundleEntry = bundle.verificationMaterial.tlogEntries[0]; + // Rekor v2 has no authenticated integrated time. Protobuf JSON may omit the + // field, encode it as null, or expose the wire value as either 0 or "0". + // Upstream 0.1.14 treats the non-empty string "0" as Unix epoch, so + // normalize only these version-coupled sentinels and let the required + // RFC3161 timestamp supply authenticated observer time. + const verificationBundle = + bundleEntry.kindVersion.version === "0.0.2" + ? { + ...bundle, + verificationMaterial: { + ...bundle.verificationMaterial, + tlogEntries: [{ ...bundleEntry, integratedTime: null }] + } + } + : bundle; + const verifier = new SigstoreVerifier({ + tlogThreshold: 1, + ctlogThreshold: 1, + tsaThreshold: 1 + }); + await verifier.loadSigstoreRoot(verificationRoot as TrustedRoot); + const verified = await verifier.verifyArtifactPolicy( + new ExactTufTargetPolicy(), + verificationBundle as SigstoreBundle, + manifestBytes, + false + ); + if (!verified) throw new Error("Sigstore verifier did not authenticate the manifest."); + + const signatureBytes = decodeBase64(bundle.messageSignature.signature); + const observerTimes = await verifyBundleTimestamp( + bundle.verificationMaterial.timestampVerificationData, + signatureBytes, + trustedRoot.timestampAuthorities + ); + if (observerTimes.length < 1) { + throw new Error("Sigstore bundle has no authenticated observer timestamp."); + } + await assertFullCertificatePathAtObserverTime( + bundle.verificationMaterial.certificate.rawBytes, + trustedRoot, + observerTimes + ); + + const entry = bundle.verificationMaterial.tlogEntries[0]; + return Object.freeze({ + logIndex: entry.logIndex, + logId: entry.logId.keyId, + observerTimestamp: observerTimes[0].toISOString() + }); +} diff --git a/sdk/src/lib/test/api-url-loader.ts b/sdk/src/lib/test/api-url-loader.ts index e07086c63..1e53cb65b 100644 --- a/sdk/src/lib/test/api-url-loader.ts +++ b/sdk/src/lib/test/api-url-loader.ts @@ -3,7 +3,9 @@ import { parseTestPcrEnvironment } from "./testPcrEnvironment"; // Get the API URL from environment variables const apiUrl = process.env.VITE_OPEN_SECRET_API_URL; -const pcrEnvironment = parseTestPcrEnvironment(process.env.VITE_OPEN_SECRET_PCR_ENVIRONMENT); +const pcrEnvironment = parseTestPcrEnvironment( + process.env.VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT +); if (!apiUrl) { throw new Error("VITE_OPEN_SECRET_API_URL must be set in environment variables"); diff --git a/sdk/src/lib/test/customFetch.test.ts b/sdk/src/lib/test/customFetch.test.ts index 5109364b6..d27123cd5 100644 --- a/sdk/src/lib/test/customFetch.test.ts +++ b/sdk/src/lib/test/customFetch.test.ts @@ -1166,19 +1166,10 @@ describe("createCustomFetch stale-session recovery", () => { test("forwards one endpoint-bound PCR policy through lookup and renewal", async () => { const apiUrl = "https://enclave.example.test/base"; const pcrConfig: PcrConfig = { - environment: "development", - pcr0DevValues: ["2a".repeat(48)], - remoteAttestation: false + environment: "dev" }; const expectedPcrConfig: PcrConfig = { - environment: "development", - pcr0Values: [], - pcr0DevValues: ["2a".repeat(48)], - remoteAttestation: false, - remoteAttestationUrls: { - prod: "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrProdHistory.json", - dev: "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrDevHistory.json" - } + environment: "dev" }; const calls: Array<[boolean | undefined, string | undefined, PcrConfig | undefined]> = []; let currentAttestation = staleAttestation; @@ -1193,9 +1184,7 @@ describe("createCustomFetch stale-session recovery", () => { }, fetch: async (_input, init) => { if (recordRequest(init).sessionId === staleAttestation.sessionId) { - pcrConfig.environment = "production"; - pcrConfig.pcr0DevValues = ["4c".repeat(48)]; - pcrConfig.remoteAttestation = true; + pcrConfig.environment = "prod"; return contractError(400, "stale", "session_not_found"); } return Response.json({ encrypted: '2:{"ok":true}' }); @@ -1224,8 +1213,7 @@ describe("createCustomFetch stale-session recovery", () => { const originalPcrConfig = getApiPcrConfig(); const apiUrl = "https://provider.example.test"; const pcrConfig: PcrConfig = { - pcr0Values: ["3b".repeat(48)], - remoteAttestation: false + environment: "prod" }; const calls: Array<[boolean | undefined, string | undefined, PcrConfig | undefined]> = []; @@ -1249,7 +1237,7 @@ describe("createCustomFetch stale-session recovery", () => { expect(calls[0][0]).toBe(false); expect(calls[0][1]).toBe(apiUrl); expect(calls[0][2]).toEqual(expect.objectContaining(pcrConfig)); - expect(calls[0][2]?.environment).toBe("production"); + expect(calls[0][2]?.environment).toBe("prod"); } finally { setApiUrl(originalApiUrl, originalPcrConfig); } diff --git a/sdk/src/lib/test/encryptedApi.test.ts b/sdk/src/lib/test/encryptedApi.test.ts index 281e8f44e..2c6d9237c 100644 --- a/sdk/src/lib/test/encryptedApi.test.ts +++ b/sdk/src/lib/test/encryptedApi.test.ts @@ -43,10 +43,10 @@ function dependencies(overrides: Partial = {}): Encryp encryptMessage: encryptForTest, fetch: async () => new Response(null, { status: 500 }), getAttestation: async () => staleAttestation, - getApiPcrConfig: () => snapshotPcrConfig({ environment: "development" }), + getApiPcrConfig: () => snapshotPcrConfig({ environment: "dev" }), getApiUrl: () => "https://api.example.test", getPlatformApiUrl: () => "https://platform.example.test", - getPlatformPcrConfig: () => snapshotPcrConfig({ environment: "development" }), + getPlatformPcrConfig: () => snapshotPcrConfig({ environment: "dev" }), getAccessToken: () => window.localStorage.getItem("access_token"), refreshAccessToken: async () => {}, resolveEndpoint: (url) => ({ diff --git a/sdk/src/lib/test/fixtures/README.md b/sdk/src/lib/test/fixtures/README.md new file mode 100644 index 000000000..499524757 --- /dev/null +++ b/sdk/src/lib/test/fixtures/README.md @@ -0,0 +1,33 @@ +# Sigstore browser test fixtures + +`sigstore-production-root.json` is test-only trust material copied from +`tinfoilsh/tinfoil-go/verifier/client/trusted_root.json` at tinfoil-go commit +`074ab5154777cbf1126c7230985433580c7c29d5`. The source file SHA-256 is +`6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66`. +Prettier changes JSON whitespace in this repository; the copied fixture SHA-256 +is `84d95b8389e45dc35f9d22f2a2f30d3f427644ad348c97e3b9f43f49efcb02ad`, +and both files have the same sorted compact-JSON SHA-256 +`05a094edfa2e8eb7d1b4b37244e28ffb6d86b8b86ce3eb122b644b4335ae4dc1`. + +The positive portable bundle is shared with the Rust SDK at +`sdk/rust/tests/fixtures/cosign-v3-blob.sigstore.json`; its SHA-256 is +`ed70e4cadbe916b31d1c9fe913f6ae8d799b5cc3336b104ec839f65cd16befdd` +and it signs the exact bytes `test content for cosign\n`. + +The shared `rekor-v2-*` fixtures live beside the Rust fixture at +`sdk/rust/tests/fixtures/`. They are the official `rekor2-happy-path` +conformance case copied byte-for-byte from `sigstore/sigstore-conformance` +commit `9a5d9e9c5171eb56df7821e342eda7122f764b43`. Their upstream paths are +`test/assets/bundle-verify/rekor2-happy-path/{bundle.sigstore.json,trusted_root.json}` +and `test/assets/bundle-verify/a.txt`. The raw fixture SHA-256 values are: + +- artifact: `a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf` +- bundle: `3a5ce62cee2653969be846a41e8332eae82c633cdfafe6db48b10e60939518dd` +- trusted root: `ed6a9cf4e7c2e3297a4b5974fce0d17132f03c63512029d7aa3a402b43acab49` + +The bundle and root use a `.fixture` suffix so repository formatting cannot +change the official raw bytes. This case proves the canonical Rekor v2 wire +form, which omits `integratedTime` and relies on its RFC3161 timestamp. + +These fixtures are never imported by production SDK source or included as a +runtime trust root. Tests read them locally and perform no network requests. diff --git a/sdk/src/lib/test/fixtures/sigstore-production-root.json b/sdk/src/lib/test/fixtures/sigstore-production-root.json new file mode 100644 index 000000000..a6b5ba28f --- /dev/null +++ b/sdk/src/lib/test/fixtures/sigstore-production-root.json @@ -0,0 +1,102 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { "start": "2021-01-12T11:53:27Z" } + }, + "logId": { "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { "start": "2025-09-23T00:00:00Z" } + }, + "logId": { "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" } + } + ], + "certificateAuthorities": [ + { + "subject": { "organization": "sigstore.dev", "commonName": "sigstore" }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { "organization": "sigstore.dev", "commonName": "sigstore" }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { "start": "2022-04-13T20:06:15Z" } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { "start": "2022-10-20T00:00:00Z" } + }, + "logId": { "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { "start": "2025-07-04T00:00:00Z" } + } + ] +} diff --git a/sdk/src/lib/test/getAttestationSecurity.test.ts b/sdk/src/lib/test/getAttestationSecurity.test.ts index 0d8ddaaa1..4110c321c 100644 --- a/sdk/src/lib/test/getAttestationSecurity.test.ts +++ b/sdk/src/lib/test/getAttestationSecurity.test.ts @@ -6,7 +6,11 @@ import { getAttestationWithDependencies, type GetAttestationDependencies } from "../getAttestation"; -import type { PcrConfig } from "../pcr"; +import { + requireTrustedPcrsAgainstSnapshot, + type PcrConfig, + type TrustedEnclaveReleaseSnapshot +} from "../pcr"; const REMOTE_API_URL = "https://enclave.example.test/api"; const LOCAL_API_URL = "http://127.0.0.1:31110"; @@ -14,8 +18,20 @@ const ATTESTATION_NONCE = "00000000-0000-4000-8000-000000000001"; const TRUSTED_PCR0 = new Uint8Array(48).fill(0x2a); const SESSION_KEY = new Uint8Array(32).fill(0x5a); const PCR_CONFIG: PcrConfig = { - pcr0Values: [bytesToHex(TRUSTED_PCR0)], - remoteAttestation: false + environment: "prod" +}; +const TEST_POLICY: TrustedEnclaveReleaseSnapshot = { + environment: "prod", + sequence: 1, + policyId: "01".repeat(32), + metadataVersions: { root: 1, timestamp: 1, snapshot: 1, targets: 1 }, + expires: { + root: "2099-01-01T00:00:00.000Z", + timestamp: "2099-01-01T00:00:00.000Z", + snapshot: "2099-01-01T00:00:00.000Z", + targets: "2099-01-01T00:00:00.000Z" + }, + releases: [] }; function bytesToHex(bytes: Uint8Array): string { @@ -24,7 +40,11 @@ function bytesToHex(bytes: Uint8Array): string { function attestationDocument(pcr0?: Uint8Array): AttestationDocument { const pcrs = new Map(); - if (pcr0) pcrs.set(0, pcr0); + if (pcr0) { + pcrs.set(0, pcr0); + pcrs.set(1, new Uint8Array(48).fill(0x2b)); + pcrs.set(2, new Uint8Array(48).fill(0x2c)); + } return { module_id: "test-enclave", @@ -44,9 +64,12 @@ function dependencies( ): GetAttestationDependencies { return { verifyAttestation: async () => attestationDocument(TRUSTED_PCR0), - validatePcr0Hash: async () => ({ + resolveTrustedPcrPolicy: async () => TEST_POLICY, + requireTrustedPcrsAgainstSnapshot: async () => ({ isMatch: true, - text: "PCR0 matches a test trust root" + text: "PCR tuple matches a test trusted release", + environment: "prod", + snapshotId: "test-snapshot" }), keyExchange: async () => ({ encrypted_session_key: "test-encrypted-session-key", @@ -80,22 +103,23 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const validatePcr0Hash = mock(async () => ({ + const requireTrustedPcrsAgainstSnapshot = mock(async () => ({ isMatch: true, - text: "must not validate a missing PCR" + text: "must not validate a missing PCR", + snapshotId: "test-snapshot" })); await expect( establish( dependencies({ verifyAttestation: async () => attestationDocument(), - validatePcr0Hash, + requireTrustedPcrsAgainstSnapshot, keyExchange }) ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -105,22 +129,23 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const validatePcr0Hash = mock(async () => ({ + const requireTrustedPcrsAgainstSnapshot = mock(async () => ({ isMatch: true, - text: "must not validate a malformed PCR" + text: "must not validate a malformed PCR", + snapshotId: "test-snapshot" })); await expect( establish( dependencies({ verifyAttestation: async () => attestationDocument(new Uint8Array(47)), - validatePcr0Hash, + requireTrustedPcrsAgainstSnapshot, keyExchange }) ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -130,22 +155,21 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const validatePcr0Hash = mock(async () => ({ - isMatch: true, - text: "must not validate an all-zero PCR" - })); + const requireTrustedPcrsAgainstSnapshot = mock(async () => { + throw new Error("PCR tuple is not trusted"); + }); await expect( establish( dependencies({ verifyAttestation: async () => attestationDocument(new Uint8Array(48)), - validatePcr0Hash, + requireTrustedPcrsAgainstSnapshot, keyExchange }) ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -155,34 +179,154 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const validatePcr0Hash = mock(async () => ({ - isMatch: false, - text: "PCR0 does not match a known good value" - })); + const requireTrustedPcrsAgainstSnapshot = mock(async () => { + throw new Error("PCR tuple does not match a trusted release"); + }); - await expect(establish(dependencies({ validatePcr0Hash, keyExchange }))).rejects.toThrow( - /PCR0/i - ); + await expect( + establish(dependencies({ requireTrustedPcrsAgainstSnapshot, keyExchange })) + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).toHaveBeenCalledWith( - bytesToHex(TRUSTED_PCR0), - expect.objectContaining({ ...PCR_CONFIG, environment: "production" }) + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledWith( + expect.any(Map), + "prod", + TEST_POLICY ); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); + test("an asynchronous trust refresh failure cannot generate keys or reach key exchange", async () => { + const requireTrustedPcrsAgainstSnapshot = mock(async () => { + throw new Error("TUF timestamp is expired"); + }); + const generateNaclKeyPair = mock(() => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + })); + const keyExchange = mock(async () => ({ + encrypted_session_key: "must-not-be-used", + session_id: "must-not-be-created" + })); + + await expect( + establish( + dependencies({ requireTrustedPcrsAgainstSnapshot, generateNaclKeyPair, keyExchange }) + ) + ).rejects.toThrow("TUF timestamp is expired"); + + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledTimes(1); + expect(generateNaclKeyPair).not.toHaveBeenCalled(); + expect(keyExchange).not.toHaveBeenCalled(); + expect(window.sessionStorage.length).toBe(0); + }); + + test("loads current trust policy before creating a nonce or requesting attestation", async () => { + const events: string[] = []; + const result = await establish( + dependencies({ + resolveTrustedPcrPolicy: async () => { + events.push("policy"); + return TEST_POLICY; + }, + randomUUID: () => { + events.push("nonce"); + return ATTESTATION_NONCE; + }, + verifyAttestation: async () => { + events.push("attestation"); + return attestationDocument(TRUSTED_PCR0); + }, + requireTrustedPcrsAgainstSnapshot: async () => { + events.push("pcrs"); + return { + isMatch: true, + text: "trusted", + environment: "prod", + snapshotId: TEST_POLICY.policyId + }; + }, + keyExchange: async () => { + events.push("key-exchange"); + return { + encrypted_session_key: "test-encrypted-session-key", + session_id: "trusted-session" + }; + } + }) + ); + + expect(result.sessionId).toBe("trusted-session"); + expect(events).toEqual(["policy", "nonce", "attestation", "pcrs", "key-exchange"]); + }); + + test("a policy refresh failure happens before nonce creation or attestation GET", async () => { + const randomUUID = mock(() => ATTESTATION_NONCE); + const verifyAttestation = mock(async () => attestationDocument(TRUSTED_PCR0)); + const keyExchange = mock(async () => ({ + encrypted_session_key: "must-not-be-used", + session_id: "must-not-be-created" + })); + + await expect( + establish( + dependencies({ + resolveTrustedPcrPolicy: async () => { + throw new Error("TUF refresh unavailable"); + }, + randomUUID, + verifyAttestation, + keyExchange + }) + ) + ).rejects.toThrow("TUF refresh unavailable"); + + expect(randomUUID).not.toHaveBeenCalled(); + expect(verifyAttestation).not.toHaveBeenCalled(); + expect(keyExchange).not.toHaveBeenCalled(); + }); + + test("rechecks loaded policy expiry after attestation and before key exchange", async () => { + const expired = { + ...TEST_POLICY, + expires: { ...TEST_POLICY.expires, timestamp: "2000-01-01T00:00:00.000Z" } + }; + const keyExchange = mock(async () => ({ + encrypted_session_key: "must-not-be-used", + session_id: "must-not-be-created" + })); + + await expect( + establish( + dependencies({ + resolveTrustedPcrPolicy: async () => expired, + requireTrustedPcrsAgainstSnapshot, + keyExchange + }) + ) + ).rejects.toThrow("timestamp metadata is expired"); + expect(keyExchange).not.toHaveBeenCalled(); + }); + test("an allowed PCR0 establishes and reuses a policy-scoped cached session", async () => { const verifyAttestation = mock(async () => attestationDocument(TRUSTED_PCR0)); - const validatePcr0Hash = mock(async () => ({ + const requireTrustedPcrsAgainstSnapshot = mock(async () => ({ isMatch: true, - text: "PCR0 matches a test trust root" + text: "PCR tuple matches a test trusted release", + environment: "prod", + snapshotId: "test-snapshot" })); const keyExchange = mock(async () => ({ encrypted_session_key: "test-encrypted-session-key", session_id: "trusted-session" })); - const deps = dependencies({ verifyAttestation, validatePcr0Hash, keyExchange }); + const resolveTrustedPcrPolicy = mock(async () => TEST_POLICY); + const deps = dependencies({ + verifyAttestation, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot, + keyExchange + }); const established = await establish(deps); const cached = await establish(deps); @@ -190,10 +334,12 @@ describe("attested session establishment", () => { expect(established).toEqual({ sessionKey: SESSION_KEY, sessionId: "trusted-session" }); expect(cached).toEqual(established); expect(verifyAttestation).toHaveBeenCalledTimes(1); - expect(validatePcr0Hash).toHaveBeenCalledTimes(1); - expect(validatePcr0Hash).toHaveBeenCalledWith( - bytesToHex(TRUSTED_PCR0), - expect.objectContaining({ ...PCR_CONFIG, environment: "production" }) + expect(resolveTrustedPcrPolicy).toHaveBeenCalledTimes(1); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledTimes(1); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledWith( + expect.any(Map), + "prod", + TEST_POLICY ); expect(keyExchange).toHaveBeenCalledTimes(1); expect( @@ -205,11 +351,10 @@ describe("attested session establishment", () => { expect(window.sessionStorage.getItem("sessionId")).toBeNull(); }); - test("a policy change cannot reuse a session established under the old trust roots", async () => { + test("an environment policy change cannot reuse a prior session", async () => { await establish(dependencies()); const changedPolicy: PcrConfig = { - pcr0Values: ["4c".repeat(48)], - remoteAttestation: false + environment: "dev" }; const keyExchange = mock(async () => ({ encrypted_session_key: "must-not-be-used", @@ -221,13 +366,15 @@ describe("attested session establishment", () => { establish( dependencies({ verifyAttestation, - validatePcr0Hash: async () => ({ isMatch: false, text: "not in changed policy" }), + requireTrustedPcrsAgainstSnapshot: async () => { + throw new Error("PCR tuple is not in changed policy"); + }, keyExchange }), REMOTE_API_URL, changedPolicy ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); expect(verifyAttestation).toHaveBeenCalledTimes(1); expect(keyExchange).not.toHaveBeenCalled(); @@ -237,12 +384,10 @@ describe("attested session establishment", () => { const productionKey = await getAttestationSessionStorageKey(REMOTE_API_URL, PCR_CONFIG); const explicitProductionKey = await getAttestationSessionStorageKey(REMOTE_API_URL, { ...PCR_CONFIG, - environment: "production" + environment: "prod" }); const developmentKey = await getAttestationSessionStorageKey(REMOTE_API_URL, { - environment: "development", - pcr0DevValues: [bytesToHex(TRUSTED_PCR0)], - remoteAttestation: false + environment: "dev" }); expect(productionKey).toBe(explicitProductionKey); @@ -253,31 +398,30 @@ describe("attested session establishment", () => { await establish(dependencies()); const verifyAttestation = mock(async () => attestationDocument(TRUSTED_PCR0)); - const validatePcr0Hash = mock(async () => ({ - isMatch: false, - text: "PCR0 belongs to the production environment" - })); + const requireTrustedPcrsAgainstSnapshot = mock(async () => { + throw new Error("PCR tuple belongs to the production environment"); + }); const keyExchange = mock(async () => ({ encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); const developmentPolicy: PcrConfig = { - environment: "development", - remoteAttestation: false + environment: "dev" }; await expect( establish( - dependencies({ verifyAttestation, validatePcr0Hash, keyExchange }), + dependencies({ verifyAttestation, requireTrustedPcrsAgainstSnapshot, keyExchange }), REMOTE_API_URL, developmentPolicy ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); expect(verifyAttestation).toHaveBeenCalledTimes(1); - expect(validatePcr0Hash).toHaveBeenCalledWith( - bytesToHex(TRUSTED_PCR0), - expect.objectContaining(developmentPolicy) + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledWith( + expect.any(Map), + "dev", + TEST_POLICY ); expect(keyExchange).not.toHaveBeenCalled(); }); @@ -352,25 +496,28 @@ describe("attested session establishment", () => { }); test("bypasses PCR validation only for an exact HTTP loopback API URL", async () => { - const validatePcr0Hash = mock(async () => { + const requireTrustedPcrsAgainstSnapshot = mock(async () => { throw new Error("loopback must not invoke PCR validation"); }); const keyExchange = mock(async () => ({ encrypted_session_key: "test-encrypted-session-key", session_id: "local-session" })); + const resolveTrustedPcrPolicy = mock(async () => TEST_POLICY); const result = await establish( dependencies({ verifyAttestation: async () => attestationDocument(), - validatePcr0Hash, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot, keyExchange }), LOCAL_API_URL ); expect(result).toEqual({ sessionKey: SESSION_KEY, sessionId: "local-session" }); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(resolveTrustedPcrPolicy).not.toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).not.toHaveBeenCalled(); expect(keyExchange).toHaveBeenCalledTimes(1); }); @@ -390,7 +537,7 @@ describe("attested session establishment", () => { }), apiUrl ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR|HTTPS/i); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); diff --git a/sdk/src/lib/test/integration/attestation.test.ts b/sdk/src/lib/test/integration/attestation.test.ts index a0ac3fd14..a7699e2ee 100644 --- a/sdk/src/lib/test/integration/attestation.test.ts +++ b/sdk/src/lib/test/integration/attestation.test.ts @@ -3,8 +3,11 @@ import { createSigStructure, isLocalDevelopmentApiUrl, parseDocumentData, - parseDocumentPayload + parseDocumentPayload, + verifyAttestationWithDependencies, + type AttestationDocument } from "../../attestation"; +import type { TrustedEnclaveReleaseSnapshot } from "../../pcr"; import { encode } from "@stablelib/base64"; const HARDCODED_TEST_ATTESTATION_DOCUMENT = @@ -15,6 +18,32 @@ const EXPECTED_MODULE_ID = "i-06c79bf817127030a-enc0192d3d4945e0432"; const EXPECTED_SIGNATURE_STRUCTURE_DIGEST = "4OIYuQwzjYJFBjHw0eI4cTKT3mUCMNo0yqgPmPGOFCnoFGes3/qjUhXHbxe/HREv"; +const TEST_POLICY: TrustedEnclaveReleaseSnapshot = { + environment: "prod", + sequence: 1, + policyId: "01".repeat(32), + metadataVersions: { root: 1, timestamp: 1, snapshot: 1, targets: 1 }, + expires: { + root: "2099-01-01T00:00:00.000Z", + timestamp: "2099-01-01T00:00:00.000Z", + snapshot: "2099-01-01T00:00:00.000Z", + targets: "2099-01-01T00:00:00.000Z" + }, + releases: [] +}; + +const TEST_DOCUMENT: AttestationDocument = { + module_id: "test", + digest: "SHA384", + timestamp: Date.now(), + pcrs: new Map(), + certificate: new Uint8Array(), + cabundle: [], + public_key: new Uint8Array(32), + user_data: null, + nonce: null +}; + test("Decode document data", async () => { const parsedDocument = await parseDocumentData(HARDCODED_TEST_ATTESTATION_DOCUMENT); const parsedPayload = await parseDocumentPayload(parsedDocument.payload); @@ -34,12 +63,7 @@ test("Makes CoseSign1 bytes correctly", async () => { }); test("Recognizes local development API URLs independent of port", () => { - const localApiUrls = [ - "http://127.0.0.1:31110", - "http://localhost:31110/", - "http://0.0.0.0:31110", - "http://[::1]:31110" - ]; + const localApiUrls = ["http://127.0.0.1:31110", "http://localhost:31110/", "http://[::1]:31110"]; for (const apiUrl of localApiUrls) { expect(isLocalDevelopmentApiUrl(apiUrl)).toBe(true); @@ -51,6 +75,9 @@ test("Does not recognize production or invalid API URLs as local development URL "https://api.opensecret.cloud", "https://localhost:31110", "http://api.opensecret.cloud", + "http://0.0.0.0:31110", + "http://localhost.example.com:31110", + "http://127.0.0.1.example.com:31110", "localhost:31110", "not a url" ]; @@ -59,3 +86,23 @@ test("Does not recognize production or invalid API URLs as local development URL expect(isLocalDevelopmentApiUrl(apiUrl)).toBe(false); } }); + +test("standalone remote verification resolves policy before requesting an attestation document", async () => { + const events: string[] = []; + await verifyAttestationWithDependencies("nonce", "https://enclave.example.test", "prod", { + resolveTrustedPcrPolicy: async () => { + events.push("policy"); + return TEST_POLICY; + }, + verifyDocument: async () => { + events.push("attestation"); + return TEST_DOCUMENT; + }, + requireTrustedPcrsAgainstSnapshot: async () => { + events.push("pcrs"); + return { isMatch: true, text: "trusted", snapshotId: TEST_POLICY.policyId }; + } + }); + + expect(events).toEqual(["policy", "attestation", "pcrs"]); +}); diff --git a/sdk/src/lib/test/integration/attestationSession.test.ts b/sdk/src/lib/test/integration/attestationSession.test.ts new file mode 100644 index 000000000..7511c40f8 --- /dev/null +++ b/sdk/src/lib/test/integration/attestationSession.test.ts @@ -0,0 +1,90 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { encode } from "@stablelib/base64"; +import { + cacheAttestationSessionForTesting, + getAttestation, + getAttestationSessionStorageKey +} from "../../getAttestation"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + window.sessionStorage.clear(); + globalThis.fetch = originalFetch; +}); + +test("session cache keys include the full normalized API base path and environment", async () => { + const prodPath = await getAttestationSessionStorageKey("https://custom.example/prod/", { + environment: "prod" + }); + const devPath = await getAttestationSessionStorageKey("https://custom.example/dev", { + environment: "prod" + }); + const devEnvironment = await getAttestationSessionStorageKey("https://custom.example/prod", { + environment: "dev" + }); + + expect(prodPath).not.toBe(devPath); + expect(prodPath).not.toBe(devEnvironment); + expect(prodPath).toBe( + await getAttestationSessionStorageKey("https://custom.example/prod", { environment: "prod" }) + ); +}); + +test("reads only a valid policy-scoped unexpired cached session", async () => { + const apiUrl = "https://custom.example/prod"; + const policy = { environment: "prod" as const }; + const cacheKey = await getAttestationSessionStorageKey(apiUrl, policy); + const sessionKey = new Uint8Array(32).fill(7); + await cacheAttestationSessionForTesting( + apiUrl, + policy, + { sessionKey, sessionId: "session-id" }, + "11".repeat(48) + ); + globalThis.fetch = mock(async () => { + throw new Error("cached sessions must not fetch"); + }) as typeof fetch; + + const result = await getAttestation(false, apiUrl, policy); + expect(result.sessionKey).toEqual(sessionKey); + expect(result.sessionId).toBe("session-id"); + expect(globalThis.fetch).not.toHaveBeenCalled(); +}); + +test("never reads legacy unversioned session keys", async () => { + window.sessionStorage.setItem("sessionKey", encode(new Uint8Array(32).fill(8))); + window.sessionStorage.setItem("sessionId", "legacy-session"); + globalThis.fetch = mock(async () => { + throw new Error("fresh attestation required"); + }) as typeof fetch; + + await expect(getAttestation(false, "http://localhost:31110")).rejects.toThrow( + "fresh attestation required" + ); + expect(globalThis.fetch).toHaveBeenCalled(); + expect(window.sessionStorage.getItem("sessionKey")).toBeNull(); + expect(window.sessionStorage.getItem("sessionId")).toBeNull(); +}); + +test("rejects stale cached sessions before requesting a fresh attestation document", async () => { + const apiUrl = "https://custom.example/prod"; + const policy = { environment: "prod" as const }; + const cacheKey = await getAttestationSessionStorageKey(apiUrl, policy); + window.sessionStorage.setItem( + cacheKey, + JSON.stringify({ + sessionKey: encode(new Uint8Array(32).fill(7)), + sessionId: "stale-cache-shape" + }) + ); + globalThis.fetch = mock(async () => { + throw new Error("fresh attestation required"); + }) as typeof fetch; + + await expect(getAttestation(false, apiUrl, policy)).rejects.toThrow( + "TUF root has not been bootstrapped" + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem(cacheKey)).toBeNull(); +}); diff --git a/sdk/src/lib/test/integration/liveAttestation.test.ts b/sdk/src/lib/test/integration/liveAttestation.test.ts index 60d6fac6b..adb1a7c0a 100644 --- a/sdk/src/lib/test/integration/liveAttestation.test.ts +++ b/sdk/src/lib/test/integration/liveAttestation.test.ts @@ -19,7 +19,7 @@ afterEach(() => { }); test.skipIf(!runLive)( - "hosted enclave establishes a session only after signed-history PCR0 validation", + "hosted enclave establishes a session only after TUF-authorized release validation", async () => { const requests: string[] = []; globalThis.fetch = async (input, init) => { @@ -28,26 +28,24 @@ test.skipIf(!runLive)( }; const attestation = await getAttestation(true, liveApiUrl, { - environment: "development" + environment: "dev" }); expect(attestation.sessionKey).toHaveLength(32); expect(attestation.sessionId).toBeTruthy(); const attestationIndex = requests.findIndex((url) => url.includes("/attestation/")); - const prodHistoryIndex = requests.findIndex((url) => url.endsWith("/pcrProdHistory.json")); - const devHistoryIndex = requests.findIndex((url) => url.endsWith("/pcrDevHistory.json")); const keyExchangeIndex = requests.findIndex((url) => url.endsWith("/key_exchange")); expect(attestationIndex).toBeGreaterThanOrEqual(0); - expect(prodHistoryIndex).toBe(-1); - expect(devHistoryIndex).toBeGreaterThan(attestationIndex); - expect(keyExchangeIndex).toBeGreaterThan(devHistoryIndex); + expect(requests.some((url) => url.includes("pcrDevHistory.json"))).toBe(false); + expect(requests.some((url) => url.includes("pcrProdHistory.json"))).toBe(false); + expect(keyExchangeIndex).toBeGreaterThan(attestationIndex); } ); test.skipIf(!runLive)( - "hosted enclave cannot reach key exchange when its PCR0 policy is deliberately unknown", + "hosted enclave cannot reach key exchange under the wrong trusted-release environment", async () => { const requests: string[] = []; globalThis.fetch = async (input, init) => { @@ -57,10 +55,9 @@ test.skipIf(!runLive)( await expect( getAttestation(true, liveApiUrl, { - environment: "development", - remoteAttestation: false + environment: "prod" }) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/environment|PCR/i); expect(requests.filter((url) => url.includes("/attestation/"))).toHaveLength(1); expect(requests.filter((url) => url.endsWith("/key_exchange"))).toHaveLength(0); @@ -68,7 +65,7 @@ test.skipIf(!runLive)( ); test.skipIf(!runLive)( - "hosted development enclave is rejected by the default production policy", + "hosted development enclave is rejected by an incompatible explicit policy", async () => { const requests: string[] = []; globalThis.fetch = async (input, init) => { @@ -76,11 +73,12 @@ test.skipIf(!runLive)( return originalFetch(input, init); }; - await expect(getAttestation(true, liveApiUrl)).rejects.toThrow(/PCR0/i); + await expect(getAttestation(true, liveApiUrl, { environment: "prod" })).rejects.toThrow( + /environment|PCR/i + ); expect(requests.filter((url) => url.includes("/attestation/"))).toHaveLength(1); - expect(requests.filter((url) => url.endsWith("/pcrProdHistory.json"))).toHaveLength(1); - expect(requests.filter((url) => url.endsWith("/pcrDevHistory.json"))).toHaveLength(0); + expect(requests.filter((url) => url.includes("History.json"))).toHaveLength(0); expect(requests.filter((url) => url.endsWith("/key_exchange"))).toHaveLength(0); } ); diff --git a/sdk/src/lib/test/integration/pcr.test.ts b/sdk/src/lib/test/integration/pcr.test.ts index fc758b1a4..8d23b5dec 100644 --- a/sdk/src/lib/test/integration/pcr.test.ts +++ b/sdk/src/lib/test/integration/pcr.test.ts @@ -1,517 +1,1908 @@ -import { expect, test, mock, afterAll } from "bun:test"; -import { serializePcrConfig, snapshotPcrConfig, validatePcr0Hash, type PcrConfig } from "../../pcr"; +import { describe, expect, mock, test } from "bun:test"; +import { + assertOfficialEmbeddedBootstrapForTesting, + AttestationTufClient, + type AttestationTufClientOptions, + AttestationTrustError +} from "../../attestationTuf"; +import { + normalizeApiBaseUrl, + normalizeApiOrigin, + resolveAttestationEnvironment, + validatePcrsAgainstSnapshot +} from "../../pcr"; +import { + buildTufFixture, + FIXTURE_NOW, + hexPcrMap, + mockFetch, + PCR0, + PCR1, + PCR2 +} from "../tufFixtures"; -// Mock localStorage and other browser APIs -const storageMock = () => { - const storage: Record = {}; +mock.module("../../attestationSigstore", () => ({ + verifyTufAuthorizedSigstoreBundle: async () => ({ + logIndex: "0", + logId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + observerTimestamp: "2030-01-01T00:00:00.000Z" + }) +})); + +function createAttestationTufClientForTesting( + options: Required> & { + storage?: Storage | null; + } +): AttestationTufClient { + return new AttestationTufClient(options); +} + +function client(fixture: Awaited>) { + return createAttestationTufClientForTesting({ + fetch: fixture.fetch, + storage: fixture.storage, + now: () => FIXTURE_NOW, + bootstrap: fixture.bootstrap + }); +} + +function replaceRoutes(destination: Map, source: Map): void { + destination.clear(); + for (const [url, route] of source) destination.set(url, route); +} + +function copyStorage(source: Storage, destination: Storage): void { + const entries: Array<[string, string]> = []; + for (let index = 0; index < source.length; index += 1) { + const key = source.key(index); + if (key) entries.push([key, source.getItem(key)!]); + } + for (const [key, value] of entries) destination.setItem(key, value); +} + +function generationCacheKey(storage: Storage, environment = "prod"): string { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(`opensecret:attestation-tuf:v4:${environment}:`)) return key; + } + throw new Error(`No ${environment} attestation generation was persisted.`); +} + +function observationValues(storage: Storage): Array> { + const observations: Array> = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key?.startsWith("opensecret:attestation-tuf:v4:repository-observation:")) continue; + observations.push(JSON.parse(storage.getItem(key)!) as Record); + } + return observations; +} + +function downgradePersistedGenerationToV2(storage: Storage, environment = "prod"): void { + const key = generationCacheKey(storage, environment); + const current = JSON.parse(storage.getItem(key)!) as Record; + const { repositoryHighWater: _repository, channelHighWater: _channel, ...legacy } = current; + storage.setItem(key, JSON.stringify({ ...legacy, version: 2 })); + for (let index = storage.length - 1; index >= 0; index -= 1) { + const observationKey = storage.key(index); + if (observationKey?.startsWith("opensecret:attestation-tuf:v4:repository-observation:")) { + storage.removeItem(observationKey); + } + } +} + +const roleSeeds = ( + timestamp: readonly number[], + snapshot: readonly number[] = timestamp, + targets: readonly number[] = timestamp +) => ({ timestamp, snapshot, targets }); + +const signingSeeds = (timestamp: number, snapshot = timestamp, targets = timestamp) => ({ + timestamp, + snapshot, + targets +}); + +function storageView(backing: Storage, onGet?: () => void): Storage { return { - getItem: (key: string) => storage[key] ?? null, - setItem: (key: string, value: string) => { - storage[key] = value; + get length() { + return backing.length; }, - removeItem: (key: string) => { - delete storage[key]; + clear: backing.clear.bind(backing), + getItem(key) { + onGet?.(); + return backing.getItem(key); }, - clear: () => { - Object.keys(storage).forEach((key) => delete storage[key]); + key: backing.key.bind(backing), + removeItem: backing.removeItem.bind(backing), + setItem: backing.setItem.bind(backing) + }; +} + +function storageWithoutCleanup(backing: Storage): Storage { + return { + get length() { + return backing.length; }, - key: (i: number) => Object.keys(storage)[i] || null, - length: Object.keys(storage).length - } as Storage; -}; - -// Set up global mocks -global.localStorage = storageMock(); -global.sessionStorage = storageMock(); - -// Sample PCR0 values for testing -const VALID_PCR0_PROD = - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const VALID_PCR0_DEV = - "62c0407056217a4c10764ed9045694c29fa93255d3cc04c2f989cdd9a1f8050c8b169714c71f1118ebce2fcc9951d1a9"; -const CUSTOM_PCR0 = "11".repeat(48); -const CUSTOM_PCR0_DEV = "22".repeat(48); - -// Verified PCR0 with matching signature -const VERIFIED_PCR0 = - "cc88f0edbccb5c92a46a2c4ba542c624123a793b002d1150153def94e34f3daa288f70162a8d163c5d36b31269624cb7"; -const VERIFIED_PCR1 = - "e45de6f4e9809176f6adc68df999f87f32a602361247d5819d1edf11ac5a403cfbb609943705844251af85713a17c83a"; -const VERIFIED_PCR2 = - "7f3c7df92680edd708d19a25784d18883381cc34e16d3fe9079f7f117970ccb2eb4f403875f1340558f86a58edcdcea9"; -const VERIFIED_SIGNATURE = - "sWjjc3Plp+yYjVW45Cs7MlYjCvlx4JiYB/BKwdCLgFHqY3N+gWsGu4JNWj6PHG9FxsW1i3gGaAikh4KhYYS+ynx3wVts3HrYtsipuFkVwUVFi1BpC8foMhUFgDDPOvRa"; - -// Mock the fetch API for remote attestation with real values -const originalFetch = global.fetch; - -// Create a more complete Response-like object for TypeScript -function createMockResponse(data: any, status = 200, ok = true, redirected = false): Response { + clear: backing.clear.bind(backing), + getItem: backing.getItem.bind(backing), + key: backing.key.bind(backing), + removeItem: () => undefined, + setItem: backing.setItem.bind(backing) + }; +} + +function storageKeepingLatestObservation(backing: Storage): Storage { + const observationPrefix = "opensecret:attestation-tuf:v4:repository-observation:"; return { - ok, - status, - statusText: ok ? "OK" : "Not Found", - headers: new Headers(), - body: null, - bodyUsed: false, - redirected, - type: "basic" as ResponseType, - url: "", - json: async () => data, - text: async () => JSON.stringify(data), - arrayBuffer: async () => new ArrayBuffer(0), - blob: async () => new Blob(), - formData: async () => new FormData(), - clone: function () { - return this; + get length() { + return backing.length; + }, + clear: backing.clear.bind(backing), + getItem: backing.getItem.bind(backing), + key: backing.key.bind(backing), + removeItem: backing.removeItem.bind(backing), + setItem(key, value) { + if (key.startsWith(observationPrefix)) { + for (let index = backing.length - 1; index >= 0; index -= 1) { + const existing = backing.key(index); + if (existing?.startsWith(observationPrefix) && existing !== key) { + backing.removeItem(existing); + } + } + } + backing.setItem(key, value); } - } as Response; + }; } -// Use more precise typing for the mock function -global.fetch = mock(async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - - if (url.includes("pcr")) { - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE +describe("browser TUF attestation policy", () => { + test("fails closed before the generated production root is bootstrapped", async () => { + let fetched = false; + const tuf = createAttestationTufClientForTesting({ + fetch: (async () => { + fetched = true; + throw new Error("must not fetch"); + }) as typeof fetch, + storage: null, + now: () => FIXTURE_NOW, + bootstrap: { + schema: "https://attestations.trymaple.ai/schemas/unpublished-tuf-root/v1", + status: "unpublished", + message: "not published" } + }); + + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TUF_BOOTSTRAP_INVALID" }); + expect(fetched).toBe(false); + }); + + test("pins the official embedded bootstrap at root version one", async () => { + const fixture = await buildTufFixture({ versions: { root: 2 } }); + + expect(() => assertOfficialEmbeddedBootstrapForTesting(fixture.rootEnvelopes[2])).toThrow( + "must remain root version 1" + ); + + const injected = createAttestationTufClientForTesting({ + fetch: fixture.fetch, + storage: fixture.storage, + now: () => FIXTURE_NOW, + bootstrap: fixture.rootEnvelopes[2] + }); + await expect(injected.refresh("prod")).resolves.toMatchObject({ + metadataVersions: { root: 2 } + }); + }); + + test("does not authorize official policy without persistent browser storage", async () => { + const fixture = await buildTufFixture(); + let fetched = false; + const tuf = createAttestationTufClientForTesting({ + fetch: (async (...args: Parameters) => { + fetched = true; + return fixture.fetch(...args); + }) as typeof fetch, + storage: null, + now: () => FIXTURE_NOW, + bootstrap: fixture.bootstrap + }); + + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TRUST_CACHE_INVALID" }); + expect(fetched).toBe(false); + }); + + test("authenticates metadata and targets from only the fixed Maple origin", async () => { + const fixture = await buildTufFixture(); + const policy = await client(fixture).refresh("prod"); + + expect(policy.environment).toBe("prod"); + expect(policy.releases).toHaveLength(1); + expect(policy.releases[0].sigstore).toMatchObject({ + transparencyLog: { logIndex: "0" }, + observerTimestamp: "2030-01-01T00:00:00.000Z" + }); + expect( + fixture.requests.every(({ url }) => url.startsWith("https://attestations.trymaple.ai/tuf/")) + ).toBe(true); + expect( + fixture.requests.some(({ url }) => /github|fulcio|rekor/i.test(new URL(url).hostname)) + ).toBe(false); + for (const request of fixture.requests) { + expect(request.init).toMatchObject({ + method: "GET", + credentials: "omit", + redirect: "error", + cache: "no-store", + referrerPolicy: "no-referrer" + }); + expect(request.init?.signal).toBeInstanceOf(AbortSignal); + } + + const persisted = JSON.parse(fixture.storage.getItem(generationCacheKey(fixture.storage))!) as { + targetBytes: Record; + }; + expect(Object.keys(persisted.targetBytes).sort()).toEqual([ + "channels/prod.json", + "releases/1.0.0/prod/manifest.json", + "releases/1.0.0/prod/manifest.sigstore.json", + "sigstore/trusted_root.json" ]); - } - return createMockResponse(null, 404, false); -}); + expect(persisted.targetBytes["sigstore/trusted_root.json"]).toBeString(); + expect(persisted.targetBytes["releases/1.0.0/prod/manifest.sigstore.json"]).toBeString(); + }); -// Basic tests for PCR validation -test("validates known production PCR0 values", async () => { - const result = await validatePcr0Hash(VALID_PCR0_PROD); - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches a known good value"); -}); + test("authorizes only one complete PCR tuple and never mixes active releases", async () => { + const fixture = await buildTufFixture({ + extraReleasePcrs: { "0": PCR0, "1": "04".repeat(48), "2": "05".repeat(48) } + }); + const policy = await client(fixture).refresh("prod"); -test("validates known development PCR0 values", async () => { - const result = await validatePcr0Hash(VALID_PCR0_DEV, { - environment: "development", - remoteAttestation: false + expect(validatePcrsAgainstSnapshot(hexPcrMap(), "prod", policy).isMatch).toBe(true); + expect( + validatePcrsAgainstSnapshot( + hexPcrMap({ "0": PCR0, "1": PCR1, "2": "05".repeat(48) }), + "prod", + policy + ).isMatch + ).toBe(false); + expect(validatePcrsAgainstSnapshot(hexPcrMap(), "dev", policy).isMatch).toBe(false); + + const missing = hexPcrMap(); + missing.delete(2); + expect(validatePcrsAgainstSnapshot(missing, "prod", policy).isMatch).toBe(false); }); - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches development enclave"); -}); -test("defaults to production and rejects development PCR0 values", async () => { - const result = await validatePcr0Hash(VALID_PCR0_DEV, { remoteAttestation: false }); - expect(result.isMatch).toBe(false); -}); + test("treats an authenticated empty channel as unreleased and fails closed", async () => { + const fixture = await buildTufFixture(); + const tuf = client(fixture); + await tuf.refresh("prod"); + const cacheKey = fixture.storage.key(0)!; + const before = fixture.storage.getItem(cacheKey); + + const revoked = await buildTufFixture({ + emptyActive: true, + sequence: 2, + versions: { timestamp: 2, snapshot: 2, targets: 2 } + }); + replaceRoutes(fixture.routes as Map, revoked.routes as Map); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ + code: "POLICY_RELEASE_NOT_ACTIVE" + }); + expect(fixture.storage.getItem(cacheKey)).not.toBe(before); -test("development rejects production PCR0 values", async () => { - const result = await validatePcr0Hash(VALID_PCR0_PROD, { - environment: "development", - remoteAttestation: false + fixture.routes.set(revoked.urls.timestamp, { status: 503 }); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ + code: "POLICY_RELEASE_NOT_ACTIVE" + }); }); - expect(result.isMatch).toBe(false); -}); -test("validates custom PCR0 values", async () => { - const config: PcrConfig = { - pcr0Values: [CUSTOM_PCR0] - }; - const result = await validatePcr0Hash(CUSTOM_PCR0, config); - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches a known good value"); -}); + test("rejects more than two active release manifests", async () => { + const fixture = await buildTufFixture({ + extraReleasePcrs: { "0": "04".repeat(48), "1": "05".repeat(48), "2": "06".repeat(48) }, + thirdReleasePcrs: { "0": "07".repeat(48), "1": "08".repeat(48), "2": "09".repeat(48) } + }); + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_METADATA_INVALID" + }); + }); -test("additional PCR0 values are scoped to the selected environment", async () => { - const config: PcrConfig = { - pcr0Values: [CUSTOM_PCR0], - pcr0DevValues: [CUSTOM_PCR0_DEV], - remoteAttestation: false - }; + test.each([ + ["root", { root: "2029-12-31T23:59:59.000Z" }], + ["timestamp", { timestamp: "2030-01-01T00:00:00.000Z" }], + ["snapshot", { snapshot: "2029-12-31T23:59:59.000Z" }], + ["targets", { targets: "2029-12-31T23:59:59.000Z" }] + ])("rejects expired %s metadata", async (_role, expires) => { + const fixture = await buildTufFixture({ expires }); + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ code: "TUF_EXPIRED" }); + }); - expect((await validatePcr0Hash(CUSTOM_PCR0, config)).isMatch).toBe(true); - expect((await validatePcr0Hash(CUSTOM_PCR0_DEV, config)).isMatch).toBe(false); - expect( - ( - await validatePcr0Hash(CUSTOM_PCR0_DEV, { - ...config, - environment: "development" - }) - ).isMatch - ).toBe(true); - expect( - ( - await validatePcr0Hash(CUSTOM_PCR0, { - ...config, - environment: "development" - }) - ).isMatch - ).toBe(false); -}); + test("rejects timestamps whose validity exceeds the 48-hour client window", async () => { + const fixture = await buildTufFixture({ + expires: { timestamp: "2030-01-03T00:00:00.001Z" } + }); + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ code: "TUF_EXPIRED" }); + }); -test("snapshots a production default and rejects invalid runtime environments", () => { - expect(snapshotPcrConfig().environment).toBe("production"); - expect(snapshotPcrConfig({ environment: "production" }).environment).toBe("production"); - expect(() => snapshotPcrConfig({ environment: "staging" } as unknown as PcrConfig)).toThrow( - /environment/i - ); -}); + test("rechecks metadata expiry after all downloads complete", async () => { + const fixture = await buildTufFixture(); + let clockReads = 0; + const tuf = createAttestationTufClientForTesting({ + fetch: fixture.fetch, + storage: fixture.storage, + now: () => (clockReads++ === 0 ? FIXTURE_NOW : new Date("2030-01-03T00:00:00.000Z")), + bootstrap: fixture.bootstrap + }); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TUF_EXPIRED" }); + expect( + Array.from({ length: fixture.storage.length }, (_, index) => fixture.storage.key(index)).some( + (key) => key?.startsWith("opensecret:attestation-tuf:v4:prod:") + ) + ).toBe(false); + }); -test("serializes only the effective environment policy", () => { - const productionPolicy = serializePcrConfig(); - expect(JSON.parse(productionPolicy).version).toBe("pcr0-environment-v1"); - expect(productionPolicy).toBe(serializePcrConfig({ environment: "production" })); - expect(serializePcrConfig()).toBe(serializePcrConfig({ pcr0DevValues: [CUSTOM_PCR0_DEV] })); - expect(productionPolicy).toBe( - serializePcrConfig({ - remoteAttestationUrls: { dev: "https://unused.example.test/dev.json" } - }) - ); - expect(serializePcrConfig()).not.toBe(serializePcrConfig({ environment: "development" })); -}); + test.each([ + ["unsafe source path", { sourcePath: "../backend" }], + ["unsafe artifact name", { artifactName: "../backend.eif" }], + ["query-bearing build URI", { runUri: "https://ci.example/runs/1?token=secret" }] + ])("rejects a manifest with %s", async (_description, fixtureOptions) => { + const fixture = await buildTufFixture(fixtureOptions); + await expect(client(fixture).refresh("prod")).rejects.toBeInstanceOf(AttestationTrustError); + }); -test("rejects unknown PCR0 values when remote attestation is disabled", async () => { - const config: PcrConfig = { - remoteAttestation: false - }; - const result = await validatePcr0Hash(VERIFIED_PCR0, config); - expect(result.isMatch).toBe(false); -}); + test("does not turn source repository provenance into client authorization", async () => { + const fixture = await buildTufFixture({ sourceUri: "https://code.example/opensecret" }); + await expect(client(fixture).refresh("prod")).resolves.toMatchObject({ + releases: [{ manifest: { source: { uri: "https://code.example/opensecret" } } }] + }); + }); -test("validates PCR0 from only the selected production history by default", async () => { - const historyFetch = mock( - async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - expect(url).toContain("pcrProdHistory.json"); - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); - } - ); + test("rejects invalid signatures and non-sequential root rotation", async () => { + const badTimestamp = await buildTufFixture({ tamperTimestampSignature: true }); + await expect(client(badTimestamp).refresh("prod")).rejects.toMatchObject({ + code: "TUF_SIGNATURE_INVALID" + }); - try { - global.fetch = historyFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches remotely attested value"); - expect(result.verifiedAt).toBeDefined(); - expect(historyFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } -}); + const skippedRoot = await buildTufFixture({ + versions: { root: 2 }, + rootEnvelopeVersionOverride: 3 + }); + await expect(client(skippedRoot).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + }); -test("validates PCR0 from only the selected development history", async () => { - const historyFetch = mock( - async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - expect(url).toContain("pcrDevHistory.json"); - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE + test("rejects an invalid intermediate root before requesting a clean final root", async () => { + const fixture = await buildTufFixture({ + versions: { root: 3 }, + tamperRootSignatureVersion: 2 + }); + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_SIGNATURE_INVALID" + }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/3.root.json"))).toBe(false); + }); + + test("remembers the bootstrap authority across a first-refresh multi-root chain", async () => { + const A = 8; + const B = 9; + const C = 10; + const fixture = await buildTufFixture({ + versions: { root: 4 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([B]), + 3: roleSeeds([C]), + 4: roleSeeds([A]) + }, + metadataSigningSeeds: signingSeeds(A) + }); + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + }); + + test("rejects first-refresh reuse of retired authority material by another role", async () => { + const A = 8; + const B = 9; + const C = 10; + const D = 11; + const fixture = await buildTufFixture({ + versions: { root: 3 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A], [B], [C]), + 2: roleSeeds([D], [B], [C]), + 3: roleSeeds([B], [B], [C]) + }, + metadataSigningSeeds: signingSeeds(B, B, C) + }); + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + }); + + test("rejects independently authenticated roots that fork at the same version", async () => { + const forkA = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { 2: roleSeeds([8]) }, + metadataSigningSeeds: signingSeeds(8) + }); + const forkB = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { 2: roleSeeds([9]) }, + metadataSigningSeeds: signingSeeds(9) + }); + forkA.routes.set(forkA.urls.timestamp, { status: 503 }); + forkB.routes.set(forkB.urls.timestamp, { status: 503 }); + await expect(client(forkA).refresh("prod")).rejects.toBeInstanceOf(Error); + await expect(client(forkB).refresh("prod")).rejects.toBeInstanceOf(Error); + copyStorage(forkB.storage, forkA.storage); + forkA.requests.length = 0; + + await expect(client(forkA).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(forkA.requests).toHaveLength(0); + }); + + test("rejects a higher root chain that does not contain the accepted lower-root anchor", async () => { + const lowerFork = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { 2: roleSeeds([8]) }, + metadataSigningSeeds: signingSeeds(8) + }); + const higherFork = await buildTufFixture({ + versions: { root: 3 }, + metadataRoleKeySeedsByRootVersion: { + 2: roleSeeds([9]), + 3: roleSeeds([10]) + }, + metadataSigningSeeds: signingSeeds(10) + }); + await client(lowerFork).refresh("prod"); + await client(higherFork).refresh("prod"); + copyStorage(higherFork.storage, lowerFork.storage); + lowerFork.requests.length = 0; + + await expect(client(lowerFork).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + expect(lowerFork.requests).toHaveLength(0); + }); + + test("rejects duplicate aliases for authorized key material before network access", async () => { + const fixture = await buildTufFixture({ duplicateAuthorizedKeyMaterialAlias: true }); + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests).toHaveLength(0); + }); + + test("rejects offline root material reused by an online role before network access", async () => { + const fixture = await buildTufFixture({ rootSigningSeed: 7 }); + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests).toHaveLength(0); + }); + + test("rejects moving retired offline root material into an online role", async () => { + const fixture = await buildTufFixture({ + versions: { root: 3 }, + rootRoleKeySeedsByRootVersion: { 1: [6], 2: [8], 3: [10] }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([7]), + 2: roleSeeds([9]), + 3: roleSeeds([6], [9], [9]) + }, + metadataSigningSeeds: { timestamp: 6, snapshot: 9, targets: 9 } + }); + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + expect( + observationValues(fixture.storage).some( + (value) => value.repositoryHighWater?.root?.version === 2 + ) + ).toBe(true); + + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/3.root.json"))).toBe(true); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + }); + + test("rejects promoting previously-online material into the offline root role", async () => { + const fixture = await buildTufFixture({ + versions: { root: 3 }, + rootRoleKeySeedsByRootVersion: { 1: [6], 2: [8], 3: [7] }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([7]), + 2: roleSeeds([9]), + 3: roleSeeds([10]) + }, + metadataSigningSeeds: signingSeeds(10) + }); + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + }); + + test("accepts fresh offline and online authority rotation without crossing custody classes", async () => { + const fixture = await buildTufFixture({ + versions: { root: 3 }, + rootRoleKeySeedsByRootVersion: { 1: [6], 2: [8], 3: [10] }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([7]), + 2: roleSeeds([9]), + 3: roleSeeds([11]) + }, + metadataSigningSeeds: signingSeeds(11) + }); + + await expect(client(fixture).refresh("prod")).resolves.toMatchObject({ + metadataVersions: { root: 3 } + }); + }); + + test.each([ + ["snapshot length", { timestampSnapshotLengthDelta: 1 }], + ["snapshot hash", { timestampSnapshotHash: "ff".repeat(32) }], + ["targets length", { snapshotTargetsLengthDelta: 1 }], + ["targets hash", { snapshotTargetsHash: "ff".repeat(32) }], + ["manifest channel hash", { channelManifestHash: "ff".repeat(32) }] + ])("rejects an authenticated %s mismatch", async (_name, fixtureOptions) => { + const fixture = await buildTufFixture(fixtureOptions); + await expect(client(fixture).refresh("prod")).rejects.toBeInstanceOf(AttestationTrustError); + }); + + test("rejects a target body that no longer matches its authenticated digest", async () => { + const fixture = await buildTufFixture(); + fixture.routes.set(fixture.urls.manifest, { bytes: new TextEncoder().encode("{}") }); + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_TARGET_INTEGRITY" + }); + }); + + test("rejects redirects and both declared and streamed response overflows", async () => { + const redirected = await buildTufFixture(); + redirected.routes.set(redirected.urls.nextRoot, { + status: 404, + redirected: true, + finalUrl: "https://evil.example/2.root.json" + }); + await expect(client(redirected).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_REDIRECT" + }); + + const missingFinalUrl = await buildTufFixture(); + missingFinalUrl.routes.set(missingFinalUrl.urls.nextRoot, { status: 404, finalUrl: "" }); + await expect(client(missingFinalUrl).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_REDIRECT" + }); + + const declared = await buildTufFixture(); + declared.routes.set(declared.urls.timestamp, { + bytes: new Uint8Array(), + headers: { "content-length": "999999" } + }); + await expect(client(declared).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_SIZE_LIMIT" + }); + + const streamed = await buildTufFixture(); + streamed.routes.set(streamed.urls.manifest, { + stream: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(128 * 1024)); + controller.enqueue(new Uint8Array([1])); + controller.close(); } - ]); + }) + }); + await expect(client(streamed).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_SIZE_LIMIT" + }); + }); + + test("persists rollback high-water marks and refuses lower metadata", async () => { + const current = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + const tuf = client(current); + await tuf.refresh("prod"); + + const rolledBack = await buildTufFixture(); + replaceRoutes( + current.routes as Map, + rolledBack.routes as Map + ); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + }); + + test("preserves rollback state across reload without requiring Web Locks", async () => { + globalThis.localStorage.clear(); + try { + const current = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + const firstPage = createAttestationTufClientForTesting({ + fetch: current.fetch, + now: () => FIXTURE_NOW, + bootstrap: current.bootstrap + }); + await firstPage.refresh("prod"); + expect(generationCacheKey(globalThis.localStorage)).toContain( + "opensecret:attestation-tuf:v4:prod:" + ); + + const rolledBack = await buildTufFixture(); + const reloadedPage = createAttestationTufClientForTesting({ + fetch: rolledBack.fetch, + now: () => FIXTURE_NOW, + bootstrap: current.bootstrap + }); + await expect(reloadedPage.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + } finally { + globalThis.localStorage.clear(); } - ); + }); - try { - global.fetch = historyFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0, { environment: "development" }); - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches remotely attested value"); - expect(historyFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } -}); + test("recovers max-version floors after a disjoint replacement of every online role", async () => { + const A = 8; + const B = 9; + const high = Number.MAX_SAFE_INTEGER; + const current = await buildTufFixture({ + versions: { timestamp: high, snapshot: high, targets: high }, + sequence: high, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(current).refresh("prod"); -test("prioritizes local PCR0 values over remote ones", async () => { - // Add VERIFIED_PCR0 to the local values and ensure it uses local validation - const config: PcrConfig = { - pcr0Values: [VERIFIED_PCR0] - }; + const recovered = await buildTufFixture({ + versions: { root: 2, timestamp: 1, snapshot: 1, targets: 1 }, + sequence: 1, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([B]) + }, + metadataSigningSeeds: signingSeeds(B) + }); + replaceRoutes(current.routes as Map, recovered.routes as Map); - const result = await validatePcr0Hash(VERIFIED_PCR0, config); + await expect(client(current).refresh("prod")).resolves.toMatchObject({ sequence: 1 }); + }); - // Should match the local value, not the remote one - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches a known good value"); - // No verification timestamp since it used local validation - expect(result.verifiedAt).toBeUndefined(); -}); + test("persists a disjoint root-only recovery before a failed timestamp fetch", async () => { + const A = 8; + const B = 9; + const high = Number.MAX_SAFE_INTEGER; + const current = await buildTufFixture({ + versions: { timestamp: high, snapshot: high, targets: high }, + sequence: high, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(current).refresh("prod"); -test("handles fetch errors gracefully", async () => { - // Mock a failing fetch call - const failingFetch = mock( - async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { - throw new Error("Network error"); - } - ); + const recovered = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([B]) + }, + metadataSigningSeeds: signingSeeds(B) + }); + const timestampRoute = recovered.routes.get(recovered.urls.timestamp)!; + recovered.routes.set(recovered.urls.timestamp, { status: 503 }); + replaceRoutes(current.routes as Map, recovered.routes as Map); + await expect(client(current).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); - try { - global.fetch = failingFetch; - const result = await validatePcr0Hash("unknown-pcr-value"); - - // Should fall back to rejecting the PCR0 - expect(result.isMatch).toBe(false); - expect(result.text).toBe("PCR0 does not match a known good value"); - expect(failingFetch).toHaveBeenCalledTimes(1); - } finally { - // Restore the working fetch mock - global.fetch = originalFetch; - } -}); + current.routes.set(recovered.urls.timestamp, timestampRoute); + current.requests.length = 0; + await expect(client(current).refresh("prod")).resolves.toMatchObject({ sequence: 1 }); + expect(current.requests.some(({ url }) => url.endsWith("/metadata/2.root.json"))).toBe(false); + expect(current.requests.some(({ url }) => url.endsWith("/metadata/3.root.json"))).toBe(true); + }); -test("supports custom remote attestation URLs", async () => { - const customFetch = mock( - async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - - if (url === "https://custom.example.com/prod.json") { - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); + test("widens floors across overlap roots and does not recover through a disjoint endpoint", async () => { + const A = 8; + const B = 9; + const C = 10; + const high = Number.MAX_SAFE_INTEGER; + const current = await buildTufFixture({ + versions: { timestamp: high, snapshot: high, targets: high }, + sequence: high, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(current).refresh("prod"); + + const replay = await buildTufFixture({ + versions: { root: 3 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([A, B]), + 3: roleSeeds([B, C]) + }, + metadataSigningSeeds: signingSeeds(C) + }); + replaceRoutes(current.routes as Map, replay.routes as Map); + + await expect(client(current).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + const rootThree = observationValues(current.storage).find( + (value) => value.repositoryHighWater?.root?.version === 3 + ); + expect(rootThree?.repositoryHighWater.timestamp.authority.keyFingerprints).toHaveLength(3); + }); + + test("retains overlap provenance across reload before a single-key cutover", async () => { + const A = 8; + const B = 9; + const initial = await buildTufFixture({ + versions: { timestamp: 10, snapshot: 10, targets: 10 }, + sequence: 10, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(initial).refresh("prod"); + + const overlap = await buildTufFixture({ + versions: { root: 2, timestamp: 11, snapshot: 11, targets: 11 }, + sequence: 11, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([A, B]) + }, + metadataSigningSeeds: signingSeeds(B) + }); + replaceRoutes(initial.routes as Map, overlap.routes as Map); + await client(initial).refresh("prod"); + + const replay = await buildTufFixture({ + versions: { root: 3, timestamp: 1, snapshot: 1, targets: 1 }, + sequence: 1, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([A, B]), + 3: roleSeeds([B]) + }, + metadataSigningSeeds: signingSeeds(B) + }); + replaceRoutes(initial.routes as Map, replay.routes as Map); + + await expect(client(initial).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + }); + + test("never reauthorizes retired online-role key material", async () => { + const A = 8; + const B = 9; + const initial = await buildTufFixture({ + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(initial).refresh("prod"); + + const replacement = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([B]) + }, + metadataSigningSeeds: signingSeeds(B) + }); + replaceRoutes( + initial.routes as Map, + replacement.routes as Map + ); + await client(initial).refresh("prod"); + + const reintroduced = await buildTufFixture({ + versions: { root: 3 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([B]), + 3: roleSeeds([A]) + }, + metadataSigningSeeds: signingSeeds(A) + }); + replaceRoutes( + initial.routes as Map, + reintroduced.routes as Map + ); + initial.requests.length = 0; + + await expect(client(initial).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(initial.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + }); + + test("rejects cross-role reuse of previously authorized key material", async () => { + const A = 8; + const B = 9; + const C = 10; + const D = 11; + const initial = await buildTufFixture({ + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A], [B], [C]) }, + metadataSigningSeeds: signingSeeds(A, B, C) + }); + await client(initial).refresh("prod"); + + const replacement = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A], [B], [C]), + 2: roleSeeds([D], [B], [C]) + }, + metadataSigningSeeds: signingSeeds(D, B, C) + }); + replaceRoutes( + initial.routes as Map, + replacement.routes as Map + ); + await client(initial).refresh("prod"); + + const reused = await buildTufFixture({ + versions: { root: 3 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A], [B], [C]), + 2: roleSeeds([D], [B], [C]), + 3: roleSeeds([B], [B], [C]) + }, + metadataSigningSeeds: signingSeeds(B, B, C) + }); + replaceRoutes(initial.routes as Map, reused.routes as Map); + + await expect(client(initial).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + }); + + test("uses the candidate threshold when deciding whether authority replacement is disjoint", async () => { + const A = 8; + const B = 9; + const C = 10; + const high = Number.MAX_SAFE_INTEGER; + const initial = await buildTufFixture({ + versions: { timestamp: high, snapshot: high, targets: high }, + sequence: high, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A, B]) }, + metadataRoleThresholdsByRootVersion: { + 1: { timestamp: 2, snapshot: 2, targets: 2 } + }, + metadataSigningSeeds: { + timestamp: [A, B], + snapshot: [A, B], + targets: [A, B] } - return createMockResponse(null, 404, false); - } - ); + }); + await client(initial).refresh("prod"); - try { - global.fetch = customFetch; + const replacement = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A, B]), + 2: roleSeeds([B, C]) + }, + metadataRoleThresholdsByRootVersion: { + 1: { timestamp: 2, snapshot: 2, targets: 2 }, + 2: { timestamp: 2, snapshot: 2, targets: 2 } + }, + metadataSigningSeeds: { + timestamp: [B, C], + snapshot: [B, C], + targets: [B, C] + } + }); + replaceRoutes( + initial.routes as Map, + replacement.routes as Map + ); - const config: PcrConfig = { - remoteAttestationUrls: { - prod: "https://custom.example.com/prod.json", - dev: "https://custom.example.com/dev.json" + await expect(client(initial).refresh("prod")).resolves.toMatchObject({ sequence: 1 }); + + const retained = await buildTufFixture({ + versions: { timestamp: high, snapshot: high, targets: high }, + sequence: high, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A, B, C]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(retained).refresh("prod"); + const D = 11; + const overlappingThreshold = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A, B, C]), + 2: roleSeeds([B, C, D]) + }, + metadataRoleThresholdsByRootVersion: { + 2: { timestamp: 2, snapshot: 2, targets: 2 } + }, + metadataSigningSeeds: { + timestamp: [B, C], + snapshot: [B, C], + targets: [B, C] } + }); + replaceRoutes( + retained.routes as Map, + overlappingThreshold.routes as Map + ); + await expect(client(retained).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + }); + + test("rejects persisted provenance that omits a currently authorized key", async () => { + const A = 8; + const B = 9; + const fixture = await buildTufFixture({ + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A, B]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(fixture).refresh("prod"); + const key = generationCacheKey(fixture.storage); + const raw = JSON.parse(fixture.storage.getItem(key)!) as Record; + raw.repositoryHighWater.timestamp.authority.keyFingerprints.pop(); + fixture.storage.setItem(key, JSON.stringify(raw)); + fixture.requests.length = 0; + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_CACHE_INVALID" + }); + expect(fixture.requests).toHaveLength(0); + }); + + test("a targets-only authority replacement resets the channel sequence", async () => { + const A = 8; + const B = 9; + const initial = await buildTufFixture({ + versions: { timestamp: 10, snapshot: 10, targets: 10 }, + sequence: 10, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(initial).refresh("prod"); + + const replacement = await buildTufFixture({ + versions: { root: 2, timestamp: 11, snapshot: 11, targets: 1 }, + sequence: 1, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([A], [A], [B]) + }, + metadataSigningSeeds: signingSeeds(A, A, B) + }); + replaceRoutes( + initial.routes as Map, + replacement.routes as Map + ); + + await expect(client(initial).refresh("prod")).resolves.toMatchObject({ sequence: 1 }); + }); + + test("resets composite pointer floors when either side of their authority is replaced", async () => { + const A = 8; + const B = 9; + const initialOptions = { + versions: { timestamp: 10, snapshot: 10, targets: 10 }, + sequence: 10, + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) }; - const result = await validatePcr0Hash(VERIFIED_PCR0, config); - expect(result.isMatch).toBe(true); - expect(result.text).toBe("PCR0 matches remotely attested value"); - expect(customFetch).toHaveBeenCalledTimes(1); - expect(String(customFetch.mock.calls[0]?.[0])).toContain("/prod.json"); - } finally { - global.fetch = originalFetch; - } -}); + const parentChanged = await buildTufFixture(initialOptions); + await client(parentChanged).refresh("prod"); + const timestampRotation = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([B], [A], [A]) + }, + metadataSigningSeeds: { timestamp: B, snapshot: A, targets: A } + }); + timestampRotation.routes.set(timestampRotation.urls.timestamp, { status: 503 }); + replaceRoutes( + parentChanged.routes as Map, + timestampRotation.routes as Map + ); + await expect(client(parentChanged).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + const parentState = observationValues(parentChanged.storage).find( + (value) => value.repositoryHighWater?.root?.version === 2 + )?.repositoryHighWater; + expect(parentState?.timestamp).toBeUndefined(); + expect(parentState?.snapshotDescriptor).toBeUndefined(); + expect(parentState?.snapshot).toBeDefined(); -test("selects the custom development history without requesting production", async () => { - const customFetch = mock( - async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - expect(url).toBe("https://custom.example.com/dev.json"); - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); + const childChanged = await buildTufFixture(initialOptions); + await client(childChanged).refresh("prod"); + const snapshotRotation = await buildTufFixture({ + versions: { root: 2 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([A], [B], [A]) + }, + metadataSigningSeeds: { timestamp: A, snapshot: B, targets: A } + }); + snapshotRotation.routes.set(snapshotRotation.urls.timestamp, { status: 503 }); + replaceRoutes( + childChanged.routes as Map, + snapshotRotation.routes as Map + ); + await expect(client(childChanged).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + const childState = observationValues(childChanged.storage).find( + (value) => value.repositoryHighWater?.root?.version === 2 + )?.repositoryHighWater; + expect(childState?.timestamp).toBeDefined(); + expect(childState?.snapshotDescriptor).toBeUndefined(); + expect(childState?.snapshot).toBeUndefined(); + expect(childState?.targetsDescriptor).toBeUndefined(); + expect(childState?.targets).toBeDefined(); + }); + + test("rejects a pre-provenance legacy generation cache without network trust fallback", async () => { + const A = 8; + const B = 9; + const current = await buildTufFixture({ + versions: { root: 2, timestamp: 10, snapshot: 10, targets: 10 }, + sequence: 10, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([A, B]) + }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(current).refresh("prod"); + downgradePersistedGenerationToV2(current.storage); + current.requests.length = 0; + + await expect(client(current).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_CACHE_INVALID" + }); + expect(current.requests).toHaveLength(0); + }); + + test("rejects the pre-root-history v3 cache namespace before network access", async () => { + const fixture = await buildTufFixture(); + fixture.storage.setItem("opensecret:attestation-tuf:v3:prod:legacy", "{}"); + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TRUST_CACHE_INVALID" + }); + expect(fixture.requests).toHaveLength(0); + }); + + test("treats signature-array variation as the same signed metadata", async () => { + const fixture = await buildTufFixture(); + await client(fixture).refresh("prod"); + const repeated = await buildTufFixture(); + const route = repeated.routes.get(repeated.urls.timestamp)! as { bytes: Uint8Array }; + const envelope = JSON.parse(new TextDecoder().decode(route.bytes)) as { + signatures: Array<{ keyid: string; sig: string }>; + }; + envelope.signatures.push({ ...envelope.signatures[0] }); + repeated.routes.set(repeated.urls.timestamp, { + bytes: new TextEncoder().encode(JSON.stringify(envelope)) + }); + replaceRoutes(fixture.routes as Map, repeated.routes as Map); + await expect(client(fixture).refresh("prod")).resolves.toMatchObject({ sequence: 1 }); + }); + + test("rejects embedded-root replacement after sequential remote root rotation", async () => { + const fixture = await buildTufFixture({ versions: { root: 2 } }); + await client(fixture).refresh("prod"); + fixture.requests.length = 0; + + const upgradedSdk = createAttestationTufClientForTesting({ + fetch: fixture.fetch, + storage: fixture.storage, + now: () => FIXTURE_NOW, + bootstrap: fixture.rootEnvelopes[2] + }); + await expect(upgradedSdk.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests).toHaveLength(0); + }); + + test("rejects even an immediate embedded-root successor for persisted state", async () => { + const cached = await buildTufFixture(); + await client(cached).refresh("prod"); + const upgraded = await buildTufFixture({ versions: { root: 2 } }); + const upgradedSdk = createAttestationTufClientForTesting({ + fetch: upgraded.fetch, + storage: cached.storage, + now: () => FIXTURE_NOW, + bootstrap: upgraded.rootEnvelopes[2] + }); + + await expect(upgradedSdk.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(upgraded.requests).toHaveLength(0); + }); + + test("rejects a root-one cache that skips the embedded root-two trust epoch", async () => { + const cached = await buildTufFixture(); + await client(cached).refresh("prod"); + const upgraded = await buildTufFixture({ versions: { root: 3 } }); + const upgradedSdk = createAttestationTufClientForTesting({ + fetch: upgraded.fetch, + storage: cached.storage, + now: () => FIXTURE_NOW, + bootstrap: upgraded.rootEnvelopes[3] + }); + + await expect(upgradedSdk.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(upgraded.requests).toHaveLength(0); + }); + + test("cannot forget an unseen retired online key across a skipped embedded root", async () => { + const A = 8; + const X = 9; + const cached = await buildTufFixture({ + metadataRoleKeySeedsByRootVersion: { 1: roleSeeds([A]) }, + metadataSigningSeeds: signingSeeds(A) + }); + await client(cached).refresh("prod"); + + const skipped = await buildTufFixture({ + versions: { root: 4 }, + metadataRoleKeySeedsByRootVersion: { + 1: roleSeeds([A]), + 2: roleSeeds([X]), + 3: roleSeeds([A]), + 4: roleSeeds([X]) + }, + metadataSigningSeeds: signingSeeds(X) + }); + const upgradedClient = createAttestationTufClientForTesting({ + fetch: skipped.fetch, + storage: cached.storage, + now: () => FIXTURE_NOW, + bootstrap: skipped.rootEnvelopes[3] + }); + + await expect(upgradedClient.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(skipped.requests).toHaveLength(0); + }); + + test("rejects embedded-root replacement before evaluating cached metadata floors", async () => { + const current = await buildTufFixture({ + versions: { timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + await client(current).refresh("prod"); + + const replay = await buildTufFixture({ versions: { root: 2 }, sequence: 1 }); + const upgradedSdk = createAttestationTufClientForTesting({ + fetch: replay.fetch, + storage: current.storage, + now: () => FIXTURE_NOW, + bootstrap: replay.rootEnvelopes[2] + }); + + await expect(upgradedSdk.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(replay.requests).toHaveLength(0); + }); + + test("rejects changed-role embedded-root replacement before cached signature checks", async () => { + const current = await buildTufFixture({ + versions: { timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + await client(current).refresh("prod"); + + const incompatible = await buildTufFixture({ + versions: { root: 2 }, + signingSeed: 8 + }); + const upgradedSdk = createAttestationTufClientForTesting({ + fetch: incompatible.fetch, + storage: current.storage, + now: () => FIXTURE_NOW, + bootstrap: incompatible.rootEnvelopes[2] + }); + + await expect(upgradedSdk.refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(incompatible.requests).toHaveLength(0); + }); + + test("persists each authenticated root before probing the following root", async () => { + const initial = await buildTufFixture(); + await client(initial).refresh("prod"); + + const rotating = await buildTufFixture({ versions: { root: 2 } }); + rotating.routes.set(rotating.urls.nextRoot, { status: 503 }); + replaceRoutes(initial.routes as Map, rotating.routes as Map); + initial.requests.length = 0; + + await expect(client(initial).refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(initial.requests.some(({ url }) => url.endsWith("/metadata/2.root.json"))).toBe(true); + + initial.requests.length = 0; + const reloaded = createAttestationTufClientForTesting({ + fetch: initial.fetch, + storage: initial.storage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(reloaded.refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(initial.requests.some(({ url }) => url.endsWith("/metadata/3.root.json"))).toBe(true); + expect(initial.requests.some(({ url }) => url.endsWith("/metadata/2.root.json"))).toBe(false); + }); + + test("enforces the root-rotation ceiling across refresh restarts", async () => { + const fixture = await buildTufFixture({ versions: { root: 34 } }); + const backingStorage = fixture.storage; + fixture.storage = storageKeepingLatestObservation(backingStorage); + const root34Url = "https://attestations.trymaple.ai/tuf/metadata/34.root.json"; + + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe( + false + ); + const maximumObservation = observationValues(fixture.storage).find( + (value) => value.repositoryHighWater?.root?.version === 33 + ); + expect(maximumObservation).toBeDefined(); + expect(maximumObservation?.rootChain).toHaveLength(32); + expect( + observationValues(fixture.storage).some( + (value) => value.repositoryHighWater?.root?.version === 34 + ) + ).toBe(false); + + const root34 = fixture.routes.get(root34Url); + if (!maximumObservation || !root34?.bytes) throw new Error("missing root ceiling fixture"); + const overCapObservation = structuredClone(maximumObservation); + overCapObservation.rootChain.push(new TextDecoder().decode(root34.bytes)); + const overCapKey = "opensecret:attestation-tuf:v4:repository-observation:over-cap-replay"; + backingStorage.setItem(overCapKey, JSON.stringify(overCapObservation)); + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests).toHaveLength(0); + backingStorage.removeItem(overCapKey); + + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests.map(({ url }) => url)).toEqual([root34Url]); + expect( + observationValues(fixture.storage).some( + (value) => value.repositoryHighWater?.root?.version === 34 + ) + ).toBe(false); + + fixture.routes.set(root34Url, { status: 404 }); + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).resolves.toMatchObject({ + metadataVersions: { root: 33 } + }); + expect(fixture.requests.some(({ url }) => url.endsWith("/metadata/timestamp.json"))).toBe(true); + + for (const status of [403, 408, 429, 500, 503]) { + fixture.routes.set(root34Url, { status }); + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests.map(({ url }) => url)).toEqual([root34Url]); } - ); - try { - global.fetch = customFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0, { - environment: "development", - remoteAttestationUrls: { - prod: "https://custom.example.com/prod.json", - dev: "https://custom.example.com/dev.json" - } + const routedFetch = fixture.fetch; + fixture.fetch = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url !== root34Url) return await routedFetch(input, init); + fixture.requests.push({ url, init }); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("sentinel timeout", "AbortError")), + { once: true } + ); + }); + }) as typeof fetch; + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" }); - expect(result.isMatch).toBe(true); - expect(customFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } -}); + expect(fixture.requests.map(({ url }) => url)).toEqual([root34Url]); + fixture.fetch = routedFetch; -test("does not fall back to the opposite environment's signed history", async () => { - const historyFetch = mock( - async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("pcrDevHistory.json")) { - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); - } - return createMockResponse([ - { - PCR0: CUSTOM_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE + fixture.routes.set(root34Url, { + stream: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x7b])); + controller.error(new Error("sentinel response interrupted")); } - ]); - } - ); + }) + }); + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + expect(fixture.requests.map(({ url }) => url)).toEqual([root34Url]); - try { - global.fetch = historyFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(historyFetch).toHaveBeenCalledTimes(1); - expect(String(historyFetch.mock.calls[0]?.[0])).toContain("pcrProdHistory.json"); - } finally { - global.fetch = originalFetch; - } -}); + fixture.routes.set(root34Url, { status: 404 }); + fixture.routes.set(fixture.urls.timestamp, { status: 503 }); + fixture.requests.length = 0; + await expect(client(fixture).refresh("prod")).resolves.toMatchObject({ + metadataVersions: { root: 33 } + }); + expect(fixture.requests.map(({ url }) => url)).toEqual([root34Url, fixture.urls.timestamp]); + }, 120_000); -test("development does not fall back to the production signed history", async () => { - const historyFetch = mock( - async (input: RequestInfo | URL, _init?: RequestInit): Promise => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("pcrProdHistory.json")) { - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); + test("never falls back after authenticating a newer partial repository generation", async () => { + const initial = await buildTufFixture(); + await client(initial).refresh("prod"); + + const advanced = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + advanced.routes.set(advanced.urls.channel, { status: 404 }); + replaceRoutes(initial.routes as Map, advanced.routes as Map); + + await expect(client(initial).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + + const rolledBack = await buildTufFixture(); + const reloaded = createAttestationTufClientForTesting({ + fetch: rolledBack.fetch, + storage: initial.storage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(reloaded.refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + }); + + test("never commits a partial update and retries the same timestamp after repair", async () => { + const initial = await buildTufFixture(); + const tuf = client(initial); + await tuf.refresh("prod"); + const cacheKey = generationCacheKey(initial.storage); + const before = initial.storage.getItem(cacheKey); + + const partial = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2, + timestampSnapshotHash: "ff".repeat(32) + }); + replaceRoutes(initial.routes as Map, partial.routes as Map); + await expect(tuf.refresh("prod")).rejects.toBeInstanceOf(AttestationTrustError); + expect(initial.storage.getItem(cacheKey)).toBe(before); + + const conflictingSameVersion = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + replaceRoutes( + initial.routes as Map, + conflictingSameVersion.routes as Map + ); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + + const repaired = await buildTufFixture({ + versions: { timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + replaceRoutes(initial.routes as Map, repaired.routes as Map); + await expect(tuf.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + expect(initial.storage.getItem(cacheKey)).not.toBe(before); + }); + + test("does not authorize a generation when persistent cache commit fails", async () => { + const fixture = await buildTufFixture(); + const failingStorage: Storage = { + ...fixture.storage, + get length() { + return fixture.storage.length; + }, + getItem: fixture.storage.getItem.bind(fixture.storage), + key: fixture.storage.key.bind(fixture.storage), + removeItem: fixture.storage.removeItem.bind(fixture.storage), + clear: fixture.storage.clear.bind(fixture.storage), + setItem() { + throw new Error("quota exceeded"); } - return createMockResponse([ - { - PCR0: CUSTOM_PCR0_DEV, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); + }; + const tuf = createAttestationTufClientForTesting({ + fetch: fixture.fetch, + storage: failingStorage, + now: () => FIXTURE_NOW, + bootstrap: fixture.bootstrap + }); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TRUST_CACHE_INVALID" }); + expect(tuf.getMemoryPolicy("prod")).toBeUndefined(); + }); + + test("does not hide a signature failure behind last-known-good policy", async () => { + const initial = await buildTufFixture(); + const tuf = client(initial); + await tuf.refresh("prod"); + const invalid = await buildTufFixture({ tamperTimestampSignature: true }); + replaceRoutes(initial.routes as Map, invalid.routes as Map); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TUF_SIGNATURE_INVALID" }); + }); + + test("fails closed on an ambiguous fetch rejection that could be a blocked redirect", async () => { + const fixture = await buildTufFixture(); + const tuf = client(fixture); + await tuf.refresh("prod"); + fixture.routes.clear(); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ code: "TRUST_FETCH_FAILED" }); + + const expiredClient = createAttestationTufClientForTesting({ + fetch: mockFetch( + new Map([ + [fixture.urls.nextRoot, { status: 404 }], + [fixture.urls.timestamp, { status: 503 }] + ]) + ), + storage: fixture.storage, + now: () => new Date("2030-01-03T00:00:00.000Z"), + bootstrap: fixture.bootstrap + }); + await expect(expiredClient.refresh("prod")).rejects.toMatchObject({ + code: "TRUST_NETWORK_UNAVAILABLE" + }); + }); + + test("shares repository metadata high-water marks across prod and dev", async () => { + const prod = await buildTufFixture({ + sequence: 2, + versions: { root: 2, timestamp: 2, snapshot: 2, targets: 2 } + }); + const tuf = client(prod); + await tuf.refresh("prod"); + expect(prod.requests.filter(({ url }) => url.endsWith("/2.root.json"))).toHaveLength(1); + + const dev = await buildTufFixture({ + environment: "dev", + versions: { root: 2, timestamp: 3, snapshot: 3, targets: 3 } + }); + replaceRoutes(prod.routes as Map, dev.routes as Map); + await expect(tuf.refresh("dev")).resolves.toMatchObject({ environment: "dev" }); + expect(prod.requests.filter(({ url }) => url.endsWith("/2.root.json"))).toHaveLength(1); + expect(prod.requests.filter(({ url }) => url.endsWith("/3.root.json"))).toHaveLength(2); + + prod.routes.set(dev.urls.timestamp, { status: 503 }); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ + code: "TRUST_NETWORK_UNAVAILABLE" + }); + }); + + test("returns a deeply immutable verified policy", async () => { + const fixture = await buildTufFixture(); + const policy = await client(fixture).refresh("prod"); + const pcrs = policy.releases[0].manifest.measurements.pcrs; + expect(Object.isFrozen(policy)).toBe(true); + expect(Object.isFrozen(policy.releases)).toBe(true); + expect(Object.isFrozen(pcrs)).toBe(true); + expect(() => { + (pcrs as { "0": string })["0"] = "ff".repeat(48); + }).toThrow(); + expect(validatePcrsAgainstSnapshot(hexPcrMap(), "prod", policy).isMatch).toBe(true); + }); + + test.each([404, 408, 429, 500, 503])( + "uses an unexpired last-known-good generation when required metadata returns HTTP %i", + async (status) => { + const fixture = await buildTufFixture(); + const tuf = client(fixture); + const first = await tuf.refresh("prod"); + fixture.routes.set(fixture.urls.timestamp, { status }); + await expect(tuf.refresh("prod")).resolves.toEqual(first); } ); - try { - global.fetch = historyFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0, { - environment: "development" + test("uses LKG after a response body is interrupted post-headers", async () => { + const fixture = await buildTufFixture(); + const tuf = client(fixture); + const first = await tuf.refresh("prod"); + fixture.routes.set(fixture.urls.timestamp, { + stream: new ReadableStream({ + start(controller) { + controller.error(new Error("connection interrupted")); + } + }) }); - expect(result.isMatch).toBe(false); - expect(historyFetch).toHaveBeenCalledTimes(1); - expect(String(historyFetch.mock.calls[0]?.[0])).toContain("pcrDevHistory.json"); - } finally { - global.fetch = originalFetch; - } -}); + await expect(tuf.refresh("prod")).resolves.toEqual(first); + }); + + test("a stale browser context cannot overwrite newer policy in shared storage", async () => { + const initial = await buildTufFixture(); + await client(initial).refresh("prod"); -test("rejects a matching remote PCR0 whose pinned-key signature is tampered", async () => { - const tamperedHistoryFetch = mock(async (): Promise => { - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: `${VERIFIED_SIGNATURE.slice(0, -2)}AA` + const stale = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + let releaseStale!: () => void; + let markStaleReached!: () => void; + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + const staleReached = new Promise((resolve) => { + markStaleReached = resolve; + }); + const pausedFetch = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === stale.urls.manifest) { + markStaleReached(); + await staleGate; } - ]); + return stale.fetch(input, init); + }) as typeof fetch; + const staleClient = createAttestationTufClientForTesting({ + fetch: pausedFetch, + storage: initial.storage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + const staleRefresh = staleClient.refresh("prod"); + await staleReached; + + const current = await buildTufFixture({ + versions: { timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + const currentClient = createAttestationTufClientForTesting({ + fetch: current.fetch, + storage: initial.storage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(currentClient.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + + releaseStale(); + await expect(staleRefresh).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + await expect(currentClient.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); }); - try { - global.fetch = tamperedHistoryFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(tamperedHistoryFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } -}); + test("rejects a held policy after another browser context commits a revocation", async () => { + const initial = await buildTufFixture(); + const staleClient = client(initial); + const stalePolicy = await staleClient.refresh("prod"); + + const revoked = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2, + pcrs: { "0": "08".repeat(48), "1": "09".repeat(48), "2": "0a".repeat(48) } + }); + replaceRoutes(initial.routes as Map, revoked.routes as Map); + const currentClient = client(initial); + const currentPolicy = await currentClient.refresh("prod"); + + await expect(staleClient.assertPolicyCurrent(stalePolicy)).rejects.toMatchObject({ + code: "TUF_ROLLBACK" + }); + await expect(currentClient.assertPolicyCurrent(currentPolicy)).resolves.toBeUndefined(); + }); -test("rejects and cancels PCR history streams larger than one MiB", async () => { - let cancelCount = 0; - const oversizedHistoryFetch = mock(async (): Promise => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(1024 * 1024).fill(0x20)); - controller.enqueue(new Uint8Array([0x20])); + test("currentness ignores a retained generation from before remote root rotation", async () => { + const initial = await buildTufFixture(); + const retainingStorage = storageWithoutCleanup(initial.storage); + const initialClient = createAttestationTufClientForTesting({ + fetch: initial.fetch, + storage: retainingStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await initialClient.refresh("prod"); + + const rotated = await buildTufFixture({ versions: { root: 2 } }); + replaceRoutes(initial.routes as Map, rotated.routes as Map); + const rotatingClient = createAttestationTufClientForTesting({ + fetch: initial.fetch, + storage: retainingStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + const policy = await rotatingClient.refresh("prod"); + const retainedGenerations = Array.from({ length: initial.storage.length }, (_, index) => + initial.storage.key(index) + ).filter((key) => key?.startsWith("opensecret:attestation-tuf:v4:prod:")); + expect(retainedGenerations).toHaveLength(2); + + expect(policy.metadataVersions.root).toBe(2); + await expect(rotatingClient.assertPolicyCurrent(policy)).resolves.toBeUndefined(); + }); + + test("a newer root-only observation after commit cannot publish a stale policy", async () => { + const initial = await buildTufFixture(); + await client(initial).refresh("prod"); + const stale = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + const current = await buildTufFixture({ + versions: { root: 2, timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + const currentTimestamp = current.routes.get(current.urls.timestamp)!; + current.routes.set(current.urls.timestamp, { status: 503 }); + + let pauseNextDigest = false; + let markStalePaused!: () => void; + let releaseStale!: () => void; + const stalePaused = new Promise((resolve) => { + markStalePaused = resolve; + }); + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + const backing = initial.storage; + const staleStorage: Storage = { + get length() { + return backing.length; }, - cancel() { - cancelCount += 1; + clear: backing.clear.bind(backing), + getItem: backing.getItem.bind(backing), + key: backing.key.bind(backing), + removeItem(key) { + backing.removeItem(key); + if (key.startsWith("opensecret:attestation-tuf:v4:prod:")) { + // Generation cleanup is the final synchronous step after commit's + // post-write generation/journal checks. Pause the next verification + // digest before the complete observation can be persisted. + pauseNextDigest = true; + } + }, + setItem: backing.setItem.bind(backing) + }; + const subtle = crypto.subtle as SubtleCrypto & { + digest: SubtleCrypto["digest"]; + }; + const originalDigest = subtle.digest.bind(subtle); + subtle.digest = (async (...args: Parameters) => { + if (pauseNextDigest) { + pauseNextDigest = false; + markStalePaused(); + await staleGate; } - }); - return new Response(stream, { status: 200 }); + return originalDigest(...args); + }) as SubtleCrypto["digest"]; + + try { + const staleClient = createAttestationTufClientForTesting({ + fetch: stale.fetch, + storage: staleStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + const staleRefresh = staleClient.refresh("prod"); + await stalePaused; + + const currentClient = createAttestationTufClientForTesting({ + fetch: current.fetch, + storage: storageView(initial.storage), + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(currentClient.refresh("prod")).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + + releaseStale(); + await expect(staleRefresh).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + expect(staleClient.getMemoryPolicy("prod")).toMatchObject({ sequence: 1 }); + + current.routes.set(current.urls.timestamp, currentTimestamp); + current.requests.length = 0; + const reloaded = createAttestationTufClientForTesting({ + fetch: current.fetch, + storage: storageView(initial.storage), + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(reloaded.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + expect(current.requests.some(({ url }) => url.endsWith("/metadata/2.root.json"))).toBe(false); + expect(current.requests.some(({ url }) => url.endsWith("/metadata/3.root.json"))).toBe(true); + } finally { + subtle.digest = originalDigest as SubtleCrypto["digest"]; + releaseStale(); + } }); - try { - global.fetch = oversizedHistoryFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(oversizedHistoryFetch).toHaveBeenCalledTimes(1); - expect(cancelCount).toBe(1); - } finally { - global.fetch = originalFetch; - } + test("a no-Web-Locks tab rechecks high-water after its immutable cache write", async () => { + const initial = await buildTufFixture(); + await client(initial).refresh("prod"); + const stale = await buildTufFixture({ + versions: { timestamp: 2, snapshot: 2, targets: 2 }, + sequence: 2 + }); + const current = await buildTufFixture({ + versions: { timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + + let staleStorageReads = 0; + let pauseNextDigest = false; + let markStalePaused!: () => void; + let releaseStale!: () => void; + const stalePaused = new Promise((resolve) => { + markStalePaused = resolve; + }); + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + const staleStorage = storageView(initial.storage, () => { + staleStorageReads += 1; + if (staleStorageReads === 2) pauseNextDigest = true; + }); + const currentStorage = storageView(initial.storage); + const subtle = crypto.subtle as SubtleCrypto & { + digest: SubtleCrypto["digest"]; + }; + const originalDigest = subtle.digest.bind(subtle); + subtle.digest = (async (...args: Parameters) => { + if (pauseNextDigest) { + pauseNextDigest = false; + markStalePaused(); + await staleGate; + } + return originalDigest(...args); + }) as SubtleCrypto["digest"]; + + try { + const staleClient = createAttestationTufClientForTesting({ + fetch: stale.fetch, + storage: staleStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + const staleRefresh = staleClient.refresh("prod"); + await stalePaused; + + const currentClient = createAttestationTufClientForTesting({ + fetch: current.fetch, + storage: currentStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(currentClient.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + releaseStale(); + await expect(staleRefresh).rejects.toMatchObject({ code: "TUF_ROLLBACK" }); + + const reloaded = createAttestationTufClientForTesting({ + fetch: current.fetch, + storage: storageView(initial.storage), + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap + }); + await expect(reloaded.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + } finally { + subtle.digest = originalDigest as SubtleCrypto["digest"]; + releaseStale(); + } + }); }); -test("rejects signed PCR history with a malformed schema", async () => { - const malformedHistoryFetch = mock(async (): Promise => { - return createMockResponse([{ PCR0: VERIFIED_PCR0 }]); +test("a compaction race cannot hide a newer persisted generation from fallback", async () => { + const initial = await buildTufFixture(); + const backing = initial.storage; + const retainingStorage: Storage = { + get length() { + return backing.length; + }, + clear: backing.clear.bind(backing), + getItem: backing.getItem.bind(backing), + key: backing.key.bind(backing), + removeItem: () => undefined, + setItem: backing.setItem.bind(backing) + }; + const first = createAttestationTufClientForTesting({ + fetch: initial.fetch, + storage: retainingStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap }); + await first.refresh("prod"); - try { - global.fetch = malformedHistoryFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(malformedHistoryFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } -}); + const newer = await buildTufFixture({ + versions: { timestamp: 3, snapshot: 3, targets: 3 }, + sequence: 3 + }); + replaceRoutes(initial.routes as Map, newer.routes as Map); + await first.refresh("prod"); + initial.routes.set(newer.urls.timestamp, { status: 503 }); -test("rejects redirected signed PCR history responses", async () => { - const redirectedHistoryFetch = mock(async (): Promise => { - return createMockResponse([], 200, true, true); + let raced = false; + const racingStorage: Storage = { + get length() { + return backing.length; + }, + clear: backing.clear.bind(backing), + getItem: backing.getItem.bind(backing), + key(index) { + const key = backing.key(index); + if (!raced && key?.startsWith("opensecret:attestation-tuf:v4:prod:")) { + raced = true; + backing.removeItem(key); + } + return key; + }, + removeItem: backing.removeItem.bind(backing), + setItem: backing.setItem.bind(backing) + }; + const reloaded = createAttestationTufClientForTesting({ + fetch: initial.fetch, + storage: racingStorage, + now: () => FIXTURE_NOW, + bootstrap: initial.bootstrap }); - try { - global.fetch = redirectedHistoryFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(redirectedHistoryFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; + await expect(reloaded.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + expect(raced).toBe(true); +}); + +test("binds exact official origins to an environment", () => { + expect(resolveAttestationEnvironment("https://api.opensecret.cloud")).toBe("prod"); + expect(resolveAttestationEnvironment("https://enclave.trymaple.ai")).toBe("prod"); + expect(resolveAttestationEnvironment("https://enclave.secretgpt.ai")).toBe("dev"); + expect(() => resolveAttestationEnvironment("https://enclave.trymaple.ai", "dev")).toThrow(); + expect(resolveAttestationEnvironment("https://custom.example", "dev")).toBe("dev"); + expect(() => resolveAttestationEnvironment("https://custom.example")).toThrow(); +}); + +test("accepts HTTPS and exact HTTP loopback URLs only", () => { + expect(normalizeApiOrigin("https://api.opensecret.cloud/v1")).toBe( + "https://api.opensecret.cloud" + ); + expect(normalizeApiOrigin("http://localhost:31110")).toBe("http://localhost:31110"); + expect(normalizeApiOrigin("http://127.0.0.1:31110")).toBe("http://127.0.0.1:31110"); + expect(normalizeApiOrigin("http://[::1]:31110")).toBe("http://[::1]:31110"); + + for (const unsafeUrl of [ + "http://api.opensecret.cloud", + "http://0.0.0.0:31110", + "http://localhost.example.com:31110", + "https://api.opensecret.cloud?environment=dev", + "https://api.opensecret.cloud#dev" + ]) { + expect(() => normalizeApiOrigin(unsafeUrl)).toThrow(); } }); -// Restore the original fetch -afterAll(() => { - global.fetch = originalFetch; +test("normalizes API base paths without collapsing distinct services", () => { + expect(normalizeApiBaseUrl("https://example.com/prod/")).toBe("https://example.com/prod"); + expect(normalizeApiBaseUrl("https://example.com/dev")).toBe("https://example.com/dev"); + expect(normalizeApiBaseUrl("https://example.com")).toBe("https://example.com"); }); diff --git a/sdk/src/lib/test/integration/platformPushSettings.test.ts b/sdk/src/lib/test/integration/platformPushSettings.test.ts index 7438ab808..167556b13 100644 --- a/sdk/src/lib/test/integration/platformPushSettings.test.ts +++ b/sdk/src/lib/test/integration/platformPushSettings.test.ts @@ -17,7 +17,7 @@ const accessToken = "push-settings-access-token"; const platformApiUrl = "https://platform.example.com"; const verifiedPcr0 = "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const pcrConfig: PcrConfig = { pcr0Values: [verifiedPcr0], remoteAttestation: false }; +const pcrConfig: PcrConfig = { environment: "prod" }; const originalFetch = globalThis.fetch; const originalPlatformApiUrl = getPlatformApiUrl(); diff --git a/sdk/src/lib/test/integration/web.test.ts b/sdk/src/lib/test/integration/web.test.ts index aa00c7233..47fefa9bc 100644 --- a/sdk/src/lib/test/integration/web.test.ts +++ b/sdk/src/lib/test/integration/web.test.ts @@ -20,7 +20,7 @@ const sessionId = "web-session-id"; const sessionKey = new Uint8Array(32).fill(19); const verifiedPcr0 = "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const pcrConfig: PcrConfig = { pcr0Values: [verifiedPcr0], remoteAttestation: false }; +const pcrConfig: PcrConfig = { environment: "prod" }; const originalFetch = globalThis.fetch; const originalApiUrl = getApiUrl(); const originalApiPcrConfig = getApiPcrConfig(); diff --git a/sdk/src/lib/test/models.test.ts b/sdk/src/lib/test/models.test.ts index 63e7c9e15..dc40cc5ca 100644 --- a/sdk/src/lib/test/models.test.ts +++ b/sdk/src/lib/test/models.test.ts @@ -9,7 +9,7 @@ const sessionId = "models-session-id"; const sessionKey = new Uint8Array(32).fill(23); const verifiedPcr0 = "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const pcrConfig: PcrConfig = { pcr0Values: [verifiedPcr0], remoteAttestation: false }; +const pcrConfig: PcrConfig = { environment: "prod" }; const modelsResponse = { object: "list" as const, data: [ diff --git a/sdk/src/lib/test/platform-api-url-loader.ts b/sdk/src/lib/test/platform-api-url-loader.ts index 3084e1244..73b6b668e 100644 --- a/sdk/src/lib/test/platform-api-url-loader.ts +++ b/sdk/src/lib/test/platform-api-url-loader.ts @@ -3,7 +3,10 @@ import { parseTestPcrEnvironment } from "./testPcrEnvironment"; // Get the API URL from environment variables const apiUrl = process.env.VITE_OPEN_SECRET_API_URL; -const pcrEnvironment = parseTestPcrEnvironment(process.env.VITE_OPEN_SECRET_PCR_ENVIRONMENT); +import { apiConfig } from "../apiConfig"; +const pcrEnvironment = parseTestPcrEnvironment( + process.env.VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT +); if (!apiUrl) { throw new Error("VITE_OPEN_SECRET_API_URL must be set in environment variables"); @@ -11,6 +14,7 @@ if (!apiUrl) { // Bind the hosted endpoint to one explicit PCR trust environment before tests run. setPlatformApiUrl(apiUrl, { environment: pcrEnvironment }); +apiConfig.configure("", apiUrl); console.log("Platform API URL set to:", apiUrl); console.log("Platform PCR trust environment set to:", pcrEnvironment); diff --git a/sdk/src/lib/test/sigstoreBrowser.test.ts b/sdk/src/lib/test/sigstoreBrowser.test.ts new file mode 100644 index 000000000..c3965a22d --- /dev/null +++ b/sdk/src/lib/test/sigstoreBrowser.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import { verifyTufAuthorizedSigstoreBundle } from "../sigstoreBrowser"; + +const encoder = new TextEncoder(); +const bundleUrl = new URL( + "../../../rust/tests/fixtures/cosign-v3-blob.sigstore.json", + import.meta.url +); +const rootUrl = new URL("./fixtures/sigstore-production-root.json", import.meta.url); +const manifestBytes = encoder.encode("test content for cosign\n"); +const rekorV2ArtifactUrl = new URL( + "../../../rust/tests/fixtures/rekor-v2-artifact.txt", + import.meta.url +); +const rekorV2BundleUrl = new URL( + "../../../rust/tests/fixtures/rekor-v2-bundle.sigstore.fixture", + import.meta.url +); +const rekorV2RootUrl = new URL( + "../../../rust/tests/fixtures/rekor-v2-trusted-root.fixture", + import.meta.url +); + +async function fixtureBytes(url: URL): Promise { + return new Uint8Array(await Bun.file(url).arrayBuffer()); +} + +async function fixtureJson(url: URL): Promise> { + return JSON.parse(await Bun.file(url).text()) as Record; +} + +function jsonBytes(value: unknown): Uint8Array { + return encoder.encode(JSON.stringify(value)); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +describe("browser Sigstore verification", () => { + test("pins the test-only bundle and trusted-root fixture bytes", async () => { + expect(await sha256Hex(await fixtureBytes(bundleUrl))).toBe( + "ed70e4cadbe916b31d1c9fe913f6ae8d799b5cc3336b104ec839f65cd16befdd" + ); + expect(await sha256Hex(await fixtureBytes(rootUrl))).toBe( + "84d95b8389e45dc35f9d22f2a2f30d3f427644ad348c97e3b9f43f49efcb02ad" + ); + expect(await sha256Hex(await fixtureBytes(rekorV2ArtifactUrl))).toBe( + "a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf" + ); + expect(await sha256Hex(await fixtureBytes(rekorV2BundleUrl))).toBe( + "3a5ce62cee2653969be846a41e8332eae82c633cdfafe6db48b10e60939518dd" + ); + expect(await sha256Hex(await fixtureBytes(rekorV2RootUrl))).toBe( + "ed6a9cf4e7c2e3297a4b5974fce0d17132f03c63512029d7aa3a402b43acab49" + ); + }); + + test("verifies the exact blob, Fulcio path/SCT, Rekor proof/checkpoint, and TSA timestamp", async () => { + const evidence = await verifyTufAuthorizedSigstoreBundle( + manifestBytes, + await fixtureBytes(bundleUrl), + await fixtureBytes(rootUrl) + ); + + expect(evidence).toEqual({ + logIndex: "738312748", + logId: "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=", + observerTimestamp: "2025-12-03T18:36:42.000Z" + }); + }); + + test("selects the TUF-authenticated Rekor key by log ID instead of root array order", async () => { + const root = await fixtureJson(rootUrl); + root.tlogs.reverse(); + + await expect( + verifyTufAuthorizedSigstoreBundle( + manifestBytes, + await fixtureBytes(bundleUrl), + jsonBytes(root) + ) + ).resolves.toMatchObject({ logIndex: "738312748" }); + }); + + test("verifies the official Rekor v2 conformance bundle with omitted integrated time", async () => { + await expect( + verifyTufAuthorizedSigstoreBundle( + await fixtureBytes(rekorV2ArtifactUrl), + await fixtureBytes(rekorV2BundleUrl), + await fixtureBytes(rekorV2RootUrl) + ) + ).resolves.toEqual({ + logIndex: "735", + logId: "8w1amZ2S5mJIQkQmPxdMuOrL/oJkvFg9MnQXmeOCXck=", + observerTimestamp: "2025-06-12T12:02:20.000Z" + }); + }); + + test('normalizes Rekor v2 integrated time "0" to the required RFC3161 time', async () => { + const bundle = await fixtureJson(rekorV2BundleUrl); + bundle.verificationMaterial.tlogEntries[0].integratedTime = "0"; + + await expect( + verifyTufAuthorizedSigstoreBundle( + await fixtureBytes(rekorV2ArtifactUrl), + jsonBytes(bundle), + await fixtureBytes(rekorV2RootUrl) + ) + ).resolves.toMatchObject({ observerTimestamp: "2025-06-12T12:02:20.000Z" }); + }); + + test("normalizes numeric Rekor v2 integrated time 0 to the required RFC3161 time", async () => { + const bundle = await fixtureJson(rekorV2BundleUrl); + bundle.verificationMaterial.tlogEntries[0].integratedTime = 0; + + await expect( + verifyTufAuthorizedSigstoreBundle( + await fixtureBytes(rekorV2ArtifactUrl), + jsonBytes(bundle), + await fixtureBytes(rekorV2RootUrl) + ) + ).resolves.toMatchObject({ observerTimestamp: "2025-06-12T12:02:20.000Z" }); + }); + + test("rejects nonzero Rekor v2 integrated time instead of treating it as authority", async () => { + const bundle = await fixtureJson(rekorV2BundleUrl); + bundle.verificationMaterial.tlogEntries[0].integratedTime = "1"; + + await expect( + verifyTufAuthorizedSigstoreBundle( + await fixtureBytes(rekorV2ArtifactUrl), + jsonBytes(bundle), + await fixtureBytes(rekorV2RootUrl) + ) + ).rejects.toThrow("strict Sigstore profile"); + }); + + test("rejects any change to the exact TUF-selected manifest bytes", async () => { + await expect( + verifyTufAuthorizedSigstoreBundle( + encoder.encode("test content for cosign\n "), + await fixtureBytes(bundleUrl), + await fixtureBytes(rootUrl) + ) + ).rejects.toThrow(); + }); + + test.each([ + ["unknown media type", (bundle: Record) => (bundle.mediaType = "unknown")], + [ + "legacy certificate chain", + (bundle: Record) => { + bundle.verificationMaterial.x509CertificateChain = { + certificates: [bundle.verificationMaterial.certificate] + }; + } + ], + [ + "DSSE envelope", + (bundle: Record) => { + delete bundle.messageSignature; + bundle.dsseEnvelope = { payload: "e30=", payloadType: "test", signatures: [] }; + } + ], + [ + "missing checkpoint", + (bundle: Record) => { + delete bundle.verificationMaterial.tlogEntries[0].inclusionProof.checkpoint; + } + ], + [ + "multiple log entries", + (bundle: Record) => { + bundle.verificationMaterial.tlogEntries.push( + structuredClone(bundle.verificationMaterial.tlogEntries[0]) + ); + } + ], + [ + "missing TSA timestamp", + (bundle: Record) => { + bundle.verificationMaterial.timestampVerificationData.rfc3161Timestamps = []; + } + ], + [ + "non-SHA256 digest profile", + (bundle: Record) => { + bundle.messageSignature.messageDigest.algorithm = "SHA2_512"; + } + ], + [ + "zero Rekor v1 integrated time", + (bundle: Record) => { + bundle.verificationMaterial.tlogEntries[0].integratedTime = "0"; + } + ], + [ + "Rekor v1 integrated time without a signed entry timestamp", + (bundle: Record) => { + delete bundle.verificationMaterial.tlogEntries[0].inclusionPromise; + } + ] + ])("rejects a %s bundle before release authorization", async (_description, mutate) => { + const bundle = await fixtureJson(bundleUrl); + mutate(bundle); + + await expect( + verifyTufAuthorizedSigstoreBundle( + manifestBytes, + jsonBytes(bundle), + await fixtureBytes(rootUrl) + ) + ).rejects.toThrow(); + }); + + test("rejects a bundle whose log ID is absent from the TUF-selected root", async () => { + const root = await fixtureJson(rootUrl); + root.tlogs = root.tlogs.filter( + (log: Record) => + log.logId.keyId !== "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + ); + + await expect( + verifyTufAuthorizedSigstoreBundle( + manifestBytes, + await fixtureBytes(bundleUrl), + jsonBytes(root) + ) + ).rejects.toThrow("exactly one TUF-authorized transparency log"); + }); +}); diff --git a/sdk/src/lib/test/testPcrEnvironment.test.ts b/sdk/src/lib/test/testPcrEnvironment.test.ts index 7a37ef35d..4fa16a84a 100644 --- a/sdk/src/lib/test/testPcrEnvironment.test.ts +++ b/sdk/src/lib/test/testPcrEnvironment.test.ts @@ -2,19 +2,19 @@ import { describe, expect, test } from "bun:test"; import { parseTestPcrEnvironment } from "./testPcrEnvironment"; describe("hosted test PCR environment", () => { - test("defaults to production when omitted", () => { - expect(parseTestPcrEnvironment(undefined)).toBe("production"); + test("defaults to prod when omitted", () => { + expect(parseTestPcrEnvironment(undefined)).toBe("prod"); }); - test("accepts exact production and development values", () => { - expect(parseTestPcrEnvironment("production")).toBe("production"); - expect(parseTestPcrEnvironment("development")).toBe("development"); + test("accepts exact prod and dev values", () => { + expect(parseTestPcrEnvironment("prod")).toBe("prod"); + expect(parseTestPcrEnvironment("dev")).toBe("dev"); }); test("rejects empty, differently-cased, or unknown values", () => { - for (const value of ["", "Production", "dev", " development "]) { + for (const value of ["", "Prod", "production", " dev "]) { expect(() => parseTestPcrEnvironment(value)).toThrow( - 'VITE_OPEN_SECRET_PCR_ENVIRONMENT must be either "production" or "development"' + 'VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT must be either "prod" or "dev"' ); } }); diff --git a/sdk/src/lib/test/testPcrEnvironment.ts b/sdk/src/lib/test/testPcrEnvironment.ts index b19c11653..17b9b0fb1 100644 --- a/sdk/src/lib/test/testPcrEnvironment.ts +++ b/sdk/src/lib/test/testPcrEnvironment.ts @@ -1,11 +1,11 @@ -import type { PcrEnvironment } from "../pcr"; +import type { AttestationEnvironment } from "../pcr"; -const PCR_ENVIRONMENT_VARIABLE = "VITE_OPEN_SECRET_PCR_ENVIRONMENT"; +const PCR_ENVIRONMENT_VARIABLE = "VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT"; /** Parse the PCR trust environment used by hosted integration tests. */ -export function parseTestPcrEnvironment(value: string | undefined): PcrEnvironment { - if (value === undefined) return "production"; - if (value === "production" || value === "development") return value; +export function parseTestPcrEnvironment(value: string | undefined): AttestationEnvironment { + if (value === undefined) return "prod"; + if (value === "prod" || value === "dev") return value; - throw new Error(`${PCR_ENVIRONMENT_VARIABLE} must be either "production" or "development"`); + throw new Error(`${PCR_ENVIRONMENT_VARIABLE} must be either "prod" or "dev"`); } diff --git a/sdk/src/lib/test/tufFixtures.ts b/sdk/src/lib/test/tufFixtures.ts new file mode 100644 index 000000000..837c3f6f9 --- /dev/null +++ b/sdk/src/lib/test/tufFixtures.ts @@ -0,0 +1,523 @@ +import nacl from "tweetnacl"; +import { canonicalJsonBytes } from "../attestationTuf"; + +export const FIXTURE_NOW = new Date("2030-01-01T00:00:00.000Z"); +export const PCR0 = "01".repeat(48); +export const PCR1 = "02".repeat(48); +export const PCR2 = "03".repeat(48); +const BASE = "https://attestations.trymaple.ai/tuf"; +const encoder = new TextEncoder(); +type OnlineRole = "timestamp" | "snapshot" | "targets"; + +export type FixtureOptions = { + environment?: "prod" | "dev"; + sequence?: number; + versions?: Partial<{ root: number; timestamp: number; snapshot: number; targets: number }>; + expires?: Partial<{ root: string; timestamp: string; snapshot: string; targets: string }>; + pcrs?: { "0": string; "1": string; "2": string }; + extraReleasePcrs?: { "0": string; "1": string; "2": string }; + thirdReleasePcrs?: { "0": string; "1": string; "2": string }; + emptyActive?: boolean; + timestampSnapshotVersion?: number; + snapshotTargetsVersion?: number; + timestampSnapshotLengthDelta?: number; + timestampSnapshotHash?: string; + snapshotTargetsLengthDelta?: number; + snapshotTargetsHash?: string; + channelManifestHash?: string; + tamperTimestampSignature?: boolean; + tamperManifestBody?: boolean; + rootEnvelopeVersionOverride?: number; + tamperRootSignatureVersion?: number; + duplicateAuthorizedKeyMaterialAlias?: boolean; + sourceUri?: string; + sourcePath?: string; + artifactName?: string; + runUri?: string; + rootSigningSeed?: number; + rootRoleKeySeedsByRootVersion?: Partial>; + rootRoleThresholdsByRootVersion?: Partial>; + signingSeed?: number; + metadataRoleKeySeedsByRootVersion?: Partial< + Record>> + >; + metadataRoleThresholdsByRootVersion?: Partial< + Record>> + >; + metadataSigningSeeds?: Partial>; +}; + +type Route = { + bytes?: Uint8Array; + status?: number; + redirected?: boolean; + finalUrl?: string; + headers?: Record; + stream?: ReadableStream; +}; + +export type TufFixture = { + bootstrap: unknown; + rootEnvelopes: Record; + routes: Map; + fetch: typeof fetch; + storage: Storage; + requests: Array<{ url: string; init?: RequestInit }>; + pcrs: { "0": string; "1": string; "2": string }; + urls: { + nextRoot: string; + timestamp: string; + snapshot: string; + targets: string; + channel: string; + bundle: string; + manifest: string; + }; +}; + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function sha256(bytes: Uint8Array): Promise { + return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))); +} + +function jsonBytes(value: unknown): Uint8Array { + return encoder.encode(JSON.stringify(value)); +} + +async function signedBy( + signedValue: Record, + signers: ReadonlyArray<{ keyid: string; signer: nacl.SignKeyPair }> +) { + return { + signatures: signers.map(({ keyid, signer }) => ({ + keyid, + sig: toHex(nacl.sign.detached(canonicalJsonBytes(signedValue), signer.secretKey)) + })), + signed: signedValue + }; +} + +async function descriptor(bytes: Uint8Array) { + return { length: bytes.byteLength, hashes: { sha256: await sha256(bytes) } }; +} + +function consistentTargetUrl(path: string, digest: string): string { + const separator = path.lastIndexOf("/"); + const directory = separator === -1 ? "" : path.slice(0, separator + 1); + const basename = path.slice(separator + 1); + return `${BASE}/targets/${directory}${digest}.${basename}`; +} + +function makeStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key) { + return values.get(key) ?? null; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + removeItem(key) { + values.delete(key); + }, + setItem(key, value) { + values.set(key, String(value)); + }, + [Symbol.iterator]() { + return values.keys(); + } + }; +} + +function responseFor(url: string, route: Route): Response { + const response = route.stream + ? new Response(route.stream, { status: route.status ?? 200, headers: route.headers }) + : new Response(route.bytes ?? new Uint8Array(), { + status: route.status ?? 200, + headers: route.headers + }); + Object.defineProperties(response, { + url: { value: route.finalUrl ?? url }, + redirected: { value: route.redirected ?? false } + }); + return response; +} + +export function mockFetch( + routes: Map, + requests: Array<{ url: string; init?: RequestInit }> = [] +): typeof fetch { + return (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + requests.push({ url, init }); + const route = routes.get(url); + if (!route) throw new Error(`Unexpected TUF URL: ${url}`); + return responseFor(url, route); + }) as typeof fetch; +} + +export async function buildTufFixture(options: FixtureOptions = {}): Promise { + const defaultSeed = options.signingSeed ?? 7; + const environment = options.environment ?? "prod"; + const versions = { + root: options.versions?.root ?? 1, + timestamp: options.versions?.timestamp ?? 1, + snapshot: options.versions?.snapshot ?? 1, + targets: options.versions?.targets ?? 1 + }; + const expires = { + root: options.expires?.root ?? "2030-12-31T00:00:00.000Z", + timestamp: options.expires?.timestamp ?? "2030-01-02T23:00:00.000Z", + snapshot: options.expires?.snapshot ?? "2030-02-01T00:00:00.000Z", + targets: options.expires?.targets ?? "2030-03-01T00:00:00.000Z" + }; + const pcrs = options.pcrs ?? { "0": PCR0, "1": PCR1, "2": PCR2 }; + const materialForSeed = async (seed: number) => { + const signer = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(seed)); + const key = { + keytype: "ed25519" as const, + scheme: "ed25519" as const, + keyval: { public: toHex(signer.publicKey) } + }; + return { signer, key, keyid: await sha256(canonicalJsonBytes(key)) }; + }; + const rootSeedsAt = (version: number): readonly number[] => { + let seeds: readonly number[] | undefined; + for (const [declaredVersion, declaredSeeds] of Object.entries( + options.rootRoleKeySeedsByRootVersion ?? {} + )) { + if (Number(declaredVersion) <= version) seeds = declaredSeeds; + } + return seeds ?? [options.rootSigningSeed ?? 6]; + }; + const rootThresholdAt = (version: number): number => { + let threshold: number | undefined; + for (const [declaredVersion, declaredThreshold] of Object.entries( + options.rootRoleThresholdsByRootVersion ?? {} + )) { + if (Number(declaredVersion) <= version) threshold = declaredThreshold; + } + return threshold ?? 1; + }; + const roleSeedsAt = (version: number, role: OnlineRole): readonly number[] => { + let seeds: readonly number[] | undefined; + for (const [declaredVersion, roles] of Object.entries( + options.metadataRoleKeySeedsByRootVersion ?? {} + )) { + if (Number(declaredVersion) <= version && roles?.[role]) seeds = roles[role]; + } + return seeds ?? [defaultSeed]; + }; + const roleThresholdAt = (version: number, role: OnlineRole): number => { + let threshold: number | undefined; + for (const [declaredVersion, roles] of Object.entries( + options.metadataRoleThresholdsByRootVersion ?? {} + )) { + if (Number(declaredVersion) <= version && roles?.[role] !== undefined) { + threshold = roles[role]; + } + } + return threshold ?? 1; + }; + const rootForVersion = async (version: number, signedVersion = version) => { + const rootMaterials = await Promise.all(rootSeedsAt(version).map(materialForSeed)); + const previousRootMaterials = + version === 1 + ? rootMaterials + : await Promise.all(rootSeedsAt(version - 1).map(materialForSeed)); + const keys: Record>["key"]> = {}; + for (const material of rootMaterials) keys[material.keyid] = material.key; + const roles: Record = { + root: { + keyids: rootMaterials.map((material) => material.keyid), + threshold: rootThresholdAt(version) + } + }; + for (const role of ["timestamp", "snapshot", "targets"] as const) { + const materials = await Promise.all(roleSeedsAt(version, role).map(materialForSeed)); + for (const material of materials) keys[material.keyid] = material.key; + roles[role] = { + keyids: materials.map((material) => material.keyid), + threshold: roleThresholdAt(version, role) + }; + } + if (options.duplicateAuthorizedKeyMaterialAlias && version === 1) { + const alias = "ff".repeat(32); + keys[alias] = keys[roles.timestamp.keyids[0]]; + roles.timestamp.keyids.push(alias); + } + const rootSigners = [...previousRootMaterials, ...rootMaterials].filter( + (material, index, all) => all.findIndex(({ keyid }) => keyid === material.keyid) === index + ); + return await signedBy( + { + _type: "root", + spec_version: "1.0.36", + version: signedVersion, + expires: expires.root, + consistent_snapshot: true, + keys, + roles + }, + rootSigners.map(({ keyid, signer }) => ({ keyid, signer })) + ); + }; + const bootstrapRoot = await rootForVersion(1); + const bootstrap = bootstrapRoot; + const rootEnvelopes: Record = { 1: bootstrapRoot }; + for (let version = 2; version <= versions.root; version += 1) { + rootEnvelopes[version] = await rootForVersion(version); + } + const metadataMaterials = async (role: OnlineRole) => { + const configured = options.metadataSigningSeeds?.[role]; + const seeds = + configured === undefined + ? roleSeedsAt(versions.root, role).slice(0, roleThresholdAt(versions.root, role)) + : typeof configured === "number" + ? [configured] + : configured; + return await Promise.all(seeds.map(materialForSeed)); + }; + const [timestampMaterials, snapshotMaterials, targetsMaterials] = await Promise.all([ + metadataMaterials("timestamp"), + metadataMaterials("snapshot"), + metadataMaterials("targets") + ]); + + const trustedRootPath = "sigstore/trusted_root.json"; + const channelPath = `channels/${environment}.json`; + const manifestPath = `releases/1.0.0/${environment}/manifest.json`; + const bundlePath = `releases/1.0.0/${environment}/manifest.sigstore.json`; + const trustedRootBytes = jsonBytes({ + mediaType: "application/vnd.dev.sigstore.trustedroot+json;version=0.1" + }); + const bundleBytes = jsonBytes({ mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json" }); + + const manifestValue = (releasePcrs: typeof pcrs, version = "1.0.0") => ({ + schema: "https://attestations.trymaple.ai/schemas/nitro-eif-release/v1", + component: "opensecret-backend", + environment, + release: { version }, + source: { + uri: options.sourceUri ?? "https://github.com/OpenSecretCloud/opensecret", + path: options.sourcePath ?? ".", + ref: `refs/tags/v${version}`, + revision: { algorithm: "git-sha1", digest: "12".repeat(20) } + }, + artifact: { + name: options.artifactName ?? `opensecret-${version}-${environment}.eif`, + mediaType: "application/vnd.aws.nitro.eif", + size: 123, + digests: { sha256: "13".repeat(32) } + }, + measurements: { + algorithm: "sha384", + requiredPcrs: [0, 1, 2], + pcrs: releasePcrs + }, + build: { + system: "nix", + builderId: "github-opensecret-v1", + derivation: `eif-${environment}`, + flakeLockSha256: "14".repeat(32), + runUri: options.runUri ?? "https://ci.example.test/runs/1" + } + }); + let manifestBytes = jsonBytes(manifestValue(pcrs)); + if (options.tamperManifestBody) { + manifestBytes = new Uint8Array(manifestBytes); + manifestBytes[manifestBytes.length - 2] ^= 1; + } + + const targetPayloads = new Map([ + [trustedRootPath, trustedRootBytes], + [manifestPath, manifestBytes], + [bundlePath, bundleBytes] + ]); + const targetDescriptors: Record>> = {}; + for (const [path, bytes] of targetPayloads) targetDescriptors[path] = await descriptor(bytes); + + const active: Array<{ + manifestTarget: string; + manifestSha256: string; + bundleTarget: string; + bundleSha256: string; + }> = options.emptyActive + ? [] + : [ + { + manifestTarget: manifestPath, + manifestSha256: + options.channelManifestHash ?? targetDescriptors[manifestPath].hashes.sha256, + bundleTarget: bundlePath, + bundleSha256: targetDescriptors[bundlePath].hashes.sha256 + } + ]; + if (options.extraReleasePcrs) { + const extraManifestPath = `releases/1.0.1/${environment}/manifest.json`; + const extraBundlePath = `releases/1.0.1/${environment}/manifest.sigstore.json`; + const extraManifestBytes = jsonBytes(manifestValue(options.extraReleasePcrs, "1.0.1")); + targetPayloads.set(extraManifestPath, extraManifestBytes); + targetPayloads.set(extraBundlePath, bundleBytes); + targetDescriptors[extraManifestPath] = await descriptor(extraManifestBytes); + targetDescriptors[extraBundlePath] = await descriptor(bundleBytes); + active.push({ + manifestTarget: extraManifestPath, + manifestSha256: targetDescriptors[extraManifestPath].hashes.sha256, + bundleTarget: extraBundlePath, + bundleSha256: targetDescriptors[extraBundlePath].hashes.sha256 + }); + } + if (options.thirdReleasePcrs) { + const thirdManifestPath = `releases/1.0.2/${environment}/manifest.json`; + const thirdBundlePath = `releases/1.0.2/${environment}/manifest.sigstore.json`; + const thirdManifestBytes = jsonBytes(manifestValue(options.thirdReleasePcrs, "1.0.2")); + targetPayloads.set(thirdManifestPath, thirdManifestBytes); + targetPayloads.set(thirdBundlePath, bundleBytes); + targetDescriptors[thirdManifestPath] = await descriptor(thirdManifestBytes); + targetDescriptors[thirdBundlePath] = await descriptor(bundleBytes); + active.push({ + manifestTarget: thirdManifestPath, + manifestSha256: targetDescriptors[thirdManifestPath].hashes.sha256, + bundleTarget: thirdBundlePath, + bundleSha256: targetDescriptors[thirdBundlePath].hashes.sha256 + }); + } + + const channelBytes = jsonBytes({ + schema: "https://attestations.trymaple.ai/schemas/channel/v1", + environment, + sequence: options.sequence ?? 1, + sigstoreTrustedRootTarget: { + path: trustedRootPath, + sha256: targetDescriptors[trustedRootPath].hashes.sha256 + }, + active + }); + targetPayloads.set(channelPath, channelBytes); + targetDescriptors[channelPath] = await descriptor(channelBytes); + + const targetsEnvelope = await signedBy( + { + _type: "targets", + spec_version: "1.0.36", + version: versions.targets, + expires: expires.targets, + targets: targetDescriptors + }, + targetsMaterials + ); + const targetsBytes = jsonBytes(targetsEnvelope); + const targetsMeta = await descriptor(targetsBytes); + targetsMeta.length += options.snapshotTargetsLengthDelta ?? 0; + if (options.snapshotTargetsHash) targetsMeta.hashes.sha256 = options.snapshotTargetsHash; + + const snapshotEnvelope = await signedBy( + { + _type: "snapshot", + spec_version: "1.0.36", + version: versions.snapshot, + expires: expires.snapshot, + meta: { + "targets.json": { + ...targetsMeta, + version: options.snapshotTargetsVersion ?? versions.targets + } + } + }, + snapshotMaterials + ); + const snapshotBytes = jsonBytes(snapshotEnvelope); + const snapshotMeta = await descriptor(snapshotBytes); + snapshotMeta.length += options.timestampSnapshotLengthDelta ?? 0; + if (options.timestampSnapshotHash) snapshotMeta.hashes.sha256 = options.timestampSnapshotHash; + + const timestampEnvelope = await signedBy( + { + _type: "timestamp", + spec_version: "1.0.36", + version: versions.timestamp, + expires: expires.timestamp, + meta: { + "snapshot.json": { + ...snapshotMeta, + version: options.timestampSnapshotVersion ?? versions.snapshot + } + } + }, + timestampMaterials + ); + if (options.tamperTimestampSignature) timestampEnvelope.signatures[0].sig = "00".repeat(64); + const timestampBytes = jsonBytes(timestampEnvelope); + + const routes = new Map(); + for (let version = 2; version <= versions.root; version += 1) { + const signedVersion = + version === versions.root && options.rootEnvelopeVersionOverride !== undefined + ? options.rootEnvelopeVersionOverride + : version; + const envelope = JSON.parse( + JSON.stringify( + signedVersion === version + ? rootEnvelopes[version] + : await rootForVersion(version, signedVersion) + ) + ) as { signatures: Array<{ sig: string }> }; + if (options.tamperRootSignatureVersion === version) { + envelope.signatures[0].sig = "00".repeat(64); + } + routes.set(`${BASE}/metadata/${version}.root.json`, { bytes: jsonBytes(envelope) }); + } + const nextRoot = `${BASE}/metadata/${versions.root + 1}.root.json`; + routes.set(nextRoot, { status: 404 }); + const timestampUrl = `${BASE}/metadata/timestamp.json`; + const snapshotUrl = `${BASE}/metadata/${options.timestampSnapshotVersion ?? versions.snapshot}.snapshot.json`; + const targetsUrl = `${BASE}/metadata/${options.snapshotTargetsVersion ?? versions.targets}.targets.json`; + routes.set(timestampUrl, { bytes: timestampBytes }); + routes.set(snapshotUrl, { bytes: snapshotBytes }); + routes.set(targetsUrl, { bytes: targetsBytes }); + for (const [path, bytes] of targetPayloads) { + routes.set(consistentTargetUrl(path, targetDescriptors[path].hashes.sha256), { bytes }); + } + const requests: Array<{ url: string; init?: RequestInit }> = []; + return { + bootstrap, + rootEnvelopes, + routes, + fetch: mockFetch(routes, requests), + storage: makeStorage(), + requests, + pcrs, + urls: { + nextRoot, + timestamp: timestampUrl, + snapshot: snapshotUrl, + targets: targetsUrl, + channel: consistentTargetUrl(channelPath, targetDescriptors[channelPath].hashes.sha256), + bundle: consistentTargetUrl(bundlePath, targetDescriptors[bundlePath].hashes.sha256), + manifest: consistentTargetUrl(manifestPath, targetDescriptors[manifestPath].hashes.sha256) + } + }; +} + +export function hexPcrMap(values = { "0": PCR0, "1": PCR1, "2": PCR2 }) { + return new Map( + ([0, 1, 2] as const).map((index) => [ + index, + new Uint8Array( + values[String(index) as "0" | "1" | "2"] + .match(/../g)! + .map((byte) => Number.parseInt(byte, 16)) + ) + ]) + ); +}