From a0c4667430bbe82706a70190c052a45e5f08a676 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:06:13 +0000 Subject: [PATCH 1/7] Consume Sigstore-authorized enclave releases --- README.md | 16 ++-- frontend/.env.example | 6 +- frontend/.env.test | 1 + frontend/src-tauri/tauri.conf.json | 2 +- frontend/src/app.tsx | 60 ++---------- frontend/src/config/attestation.test.ts | 24 +++++ frontend/src/config/attestation.ts | 23 +++++ frontend/src/routes/proof.tsx | 118 ++++++++++++++++-------- frontend/src/vite-env.d.ts | 2 +- scripts/ci/_common.sh | 4 +- 10 files changed, 155 insertions(+), 101 deletions(-) create mode 100644 frontend/src/config/attestation.test.ts create mode 100644 frontend/src/config/attestation.ts diff --git a/README.md b/README.md index 7849e8cba..79045befa 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_*` @@ -60,9 +61,9 @@ present. `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. @@ -208,9 +209,12 @@ 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 update the trusted-release snapshot in the OpenSecret +SDK. Maple does not maintain its own PCR allowlist: before key exchange, the SDK +requires the complete PCR0/PCR1/PCR2 tuple to match a release authorized for the +configured environment. Runtime clients do not fetch this policy from GitHub, +Sigstore, or Rekor. 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/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..07a843452 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -12,12 +12,11 @@ import { NotFoundFallback } from "./components/NotFoundFallback"; import { BillingServiceProvider } from "./components/BillingServiceProvider"; import { DeepLinkHandler } from "./components/DeepLinkHandler"; import { NotificationProvider } from "./contexts/NotificationContext"; -import { ChatTypographyProvider } from "./contexts/ChatTypographyContext"; 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,60 +61,21 @@ 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/routes/proof.tsx b/frontend/src/routes/proof.tsx index aaf4aa3a6..e1322799f 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 SDK's embedded snapshot of authorized tagged + releases 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} +

+ )} +

+ SDK snapshot:{" "} + {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,24 @@ 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 pinned OpenSecret release workflow and + Sigstore trust roots used to generate the SDK's embedded snapshot 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 +585,12 @@ 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 checks the full PCR0/PCR1/PCR2 tuple against an embedded snapshot + generated from a verified tagged-release manifest and Cosign bundle. You can separately + verify that release's Sigstore/Rekor evidence and rebuild the source to compare its + measurements. Sigstore does not by itself prove reproducibility or that an authorized + release is the newest.

@@ -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() { /> Date: Wed, 19 Aug 2026 19:05:13 +0000 Subject: [PATCH 2/7] Reconcile Sigstore clients with current Maple --- frontend/src-tauri/build.rs | 2 - frontend/src-tauri/src/lib.rs | 2 - frontend/src-tauri/src/maple_api.rs | 7 +--- frontend/src-tauri/src/open_secret_config.rs | 39 ------------------- frontend/src-tauri/src/proxy.rs | 6 --- .../config/openSecretPcrEnvironment.test.ts | 21 ---------- .../src/config/openSecretPcrEnvironment.ts | 11 ------ 7 files changed, 1 insertion(+), 87 deletions(-) delete mode 100644 frontend/src-tauri/src/open_secret_config.rs delete mode 100644 frontend/src/config/openSecretPcrEnvironment.test.ts delete mode 100644 frontend/src/config/openSecretPcrEnvironment.ts 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/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..5ce62e3bb 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, @@ -505,11 +504,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)?; 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/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); -} From 55ad98bcdebac89aa8f57503bc584a7501e443ba Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:08:08 +0000 Subject: [PATCH 3/7] Integrate Sigstore clients in Maple monorepo --- .github/workflows/sdk-integration.yml | 2 +- .github/workflows/sdk-typescript.yml | 6 +- README.md | 5 + frontend/src-tauri/Cargo.toml | 4 +- frontend/src-tauri/src/agent/provider.rs | 28 +- proxy/.env.example | 7 +- proxy/Cargo.toml | 8 +- proxy/Dockerfile | 1 - proxy/README.md | 60 +- proxy/docker-compose.yml | 1 - proxy/examples/library_usage.rs | 3 +- proxy/justfile | 6 +- proxy/src/config.rs | 93 -- proxy/src/lib.rs | 1 - proxy/src/main.rs | 1 - proxy/src/proxy.rs | 34 +- sdk/.env.example | 4 +- sdk/README.md | 13 +- sdk/bun.lock | 111 ++ sdk/docs/PLATFORM.md | 6 +- sdk/flake.lock | 19 +- sdk/flake.nix | 33 +- sdk/package.json | 5 +- sdk/rust/README.md | 60 +- .../trusted_enclave_releases.generated.json | 17 + sdk/rust/src/attestation.rs | 2 +- sdk/rust/src/client.rs | 149 +-- sdk/rust/src/error.rs | 8 + sdk/rust/src/lib.rs | 4 +- sdk/rust/src/pcr.rs | 585 ---------- sdk/rust/src/trusted_release.rs | 998 ++++++++++++++++++ sdk/rust/tests/attestation.rs | 16 +- sdk/rust/tests/common/mod.rs | 23 +- sdk/rust/tests/pcr_environment.rs | 14 +- .../update-trusted-enclave-releases.mjs | 656 ++++++++++++ .../update-trusted-enclave-releases.test.mjs | 106 ++ sdk/src/lib/attestation.ts | 20 +- sdk/src/lib/attestationForView.ts | 30 +- sdk/src/lib/developer.tsx | 2 +- sdk/src/lib/getAttestation.ts | 31 +- sdk/src/lib/index.ts | 11 +- sdk/src/lib/main.tsx | 2 +- sdk/src/lib/pcr.ts | 870 ++++++++------- sdk/src/lib/test/api-url-loader.ts | 4 +- sdk/src/lib/test/customFetch.test.ts | 22 +- sdk/src/lib/test/encryptedApi.test.ts | 4 +- .../lib/test/getAttestationSecurity.test.ts | 125 ++- .../lib/test/integration/attestation.test.ts | 10 +- .../integration/attestationSession.test.ts | 87 ++ .../test/integration/liveAttestation.test.ts | 28 +- sdk/src/lib/test/integration/pcr.test.ts | 643 +++-------- .../integration/platformPushSettings.test.ts | 2 +- sdk/src/lib/test/integration/web.test.ts | 2 +- sdk/src/lib/test/models.test.ts | 2 +- sdk/src/lib/test/platform-api-url-loader.ts | 6 +- sdk/src/lib/test/testPcrEnvironment.test.ts | 14 +- sdk/src/lib/test/testPcrEnvironment.ts | 12 +- .../trusted-enclave-releases.generated.json | 17 + 58 files changed, 3066 insertions(+), 1967 deletions(-) create mode 100644 sdk/rust/assets/trusted_enclave_releases.generated.json delete mode 100644 sdk/rust/src/pcr.rs create mode 100644 sdk/rust/src/trusted_release.rs create mode 100644 sdk/scripts/update-trusted-enclave-releases.mjs create mode 100644 sdk/scripts/update-trusted-enclave-releases.test.mjs create mode 100644 sdk/src/lib/test/integration/attestationSession.test.ts create mode 100644 sdk/src/lib/trusted-enclave-releases.generated.json 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-typescript.yml b/.github/workflows/sdk-typescript.yml index bf19f0885..12d3b9542 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" @@ -65,11 +67,13 @@ jobs: bun audit --audit-level=high bun run format:check bun run build + bun run test:trusted-release-updater 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 79045befa..4087d2a72 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,11 @@ requires the complete PCR0/PCR1/PCR2 tuple to match a release authorized for the configured environment. Runtime clients do not fetch this policy from GitHub, Sigstore, or Rekor. +Until a snapshot-bearing backend release is reviewed and imported, this draft +integration remains fail-closed and unavailable for real enclave connections. +Keeping the in-tree TypeScript and Rust snapshots on the same reviewed release +policy is part of the rollout requirement. + Version changes update: - `frontend/package.json` diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index d7bb8bcd0..cd7aa7c5e 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -63,8 +63,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/src/agent/provider.rs b/frontend/src-tauri/src/agent/provider.rs index 2824f2e89..b430eb1b6 100644 --- a/frontend/src-tauri/src/agent/provider.rs +++ b/frontend/src-tauri/src/agent/provider.rs @@ -916,7 +916,9 @@ fn map_opensecret_error_kind(error: opensecret::Error) -> ProviderError { ProviderError::NetworkError("The Maple network request failed".to_string()) } } - opensecret::Error::AttestationVerificationFailed(_) => { + opensecret::Error::AttestationVerificationFailed(_) + | opensecret::Error::UnreleasedAttestationPolicy { .. } + | opensecret::Error::TrustedReleasePolicy(_) => { ProviderError::ExecutionError(ATTESTATION_VERIFICATION_ERROR_MESSAGE.to_string()) } opensecret::Error::Session(_) @@ -969,7 +971,9 @@ pub(crate) fn opensecret_error_category(error: &opensecret::Error) -> &'static s 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 +2251,26 @@ 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")); + } + } + #[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/proxy/.env.example b/proxy/.env.example index 4dededced..7ec20e738 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 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..8b67befaa 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 @@ -62,8 +63,7 @@ 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 @@ -74,11 +74,12 @@ export MAPLE_STREAM_IDLE_TIMEOUT_SECS=300 # Streaming idle timeout between 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. + ## 🛠️ Usage ### Using as a Binary @@ -141,7 +142,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 +156,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); @@ -497,9 +497,47 @@ 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. verifies the AWS Nitro attestation document, certificate chain, nonce, and + signature; +2. extracts and validates the complete PCR0/PCR1/PCR2 measurement tuple; +3. compares that tuple with the release snapshot embedded in the SDK; and +4. accepts the enclave public key and performs key exchange only after the + tuple is present in that snapshot. + +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. + +There is no Sigstore, Rekor, or other release-metadata network lookup during a +runtime handshake. At SDK update time, the release-snapshot updater verifies +the release manifest and Cosign bundle, including the expected signing identity +and Rekor evidence, before generating the embedded snapshot. Consumers then +review the generated snapshot together with the SDK change. + +Sigstore makes a release statement and its signing identity tamper-evident in +an append-only transparency log. It does **not** prove that an artifact was +reproducibly built, and it does **not** make an old, previously authorized +release fresh. Reproducibility remains a separate Nix rebuild/compare property; +rollback prevention, revocation, or minimum-version policy must also be handled +separately. + +> **Integration status:** the embedded release snapshots are intentionally +> empty until the first signed backend release is reviewed and imported. Remote +> handshakes therefore fail closed in this draft branch; no release 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/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..8801e2942 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}; @@ -27,15 +26,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, @@ -87,7 +77,6 @@ impl Config { host, port, backend_url, - pcr0_environment: Pcr0Environment::Production, default_api_key: None, debug: false, enable_cors: false, @@ -104,12 +93,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); @@ -141,14 +124,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 +163,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 +173,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 +187,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..7d1330fc8 100644 --- a/proxy/src/proxy.rs +++ b/proxy/src/proxy.rs @@ -107,25 +107,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,15 +200,10 @@ 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()) @@ -256,12 +242,7 @@ 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); @@ -435,7 +416,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, 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..ae64797c7 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -29,12 +29,15 @@ 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 +trusted-release snapshot before completing key exchange. The snapshot is +generated during an SDK update only after verifying the backend release +manifest, Cosign signing identity, and Rekor transparency-log evidence; runtime +clients do not fetch policy from GitHub, Sigstore, or Rekor. 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 @@ -60,7 +63,7 @@ export function AppProviders({ children }: { children: ReactNode }) { {children} @@ -148,7 +151,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..1732ea5de 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -26,6 +26,7 @@ "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", + "sigstore": "5.0.0", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", @@ -145,6 +146,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@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=="], @@ -179,6 +182,12 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@npmcli/agent": ["@npmcli/agent@5.0.2", "", { "dependencies": { "agent-base": "^9.0.0", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "lru-cache": "^11.2.1", "socks-proxy-agent": "^10.0.0" } }, "sha512-EkzGmEsgbQ1rqWkRJe2P0oQHx/ylZozDUNPMXCklLuSFL3GY+QyEfBUjhjCsgGXzh4OGpnHvkboSQgczjP/jJg=="], + + "@npmcli/fs": ["@npmcli/fs@6.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-AheOs4swKka/XLtht6xxJDPezlQ7K2IYQ9Y8lST4JLDjnralnWuMM9AE2CdVcgQJ5omrXhsRzM7F7aYmeZBvKQ=="], + + "@npmcli/redact": ["@npmcli/redact@5.0.0", "", {}, "sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A=="], + "@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=="], "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], @@ -267,6 +276,18 @@ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.12", "", { "dependencies": { "@rushstack/terminal": "0.24.2", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-Vg2n24arSf7JvUNga2DMHYTbxnVq9L5OtVCp4Gfr8YC/kmL/bmdR8FQhGcpMzMYNY9Vdw3+TaimG11Sf+z1Tpw=="], + "@sigstore/bundle": ["@sigstore/bundle@5.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A=="], + + "@sigstore/core": ["@sigstore/core@4.0.1", "", {}, "sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.1", "", {}, "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g=="], + + "@sigstore/sign": ["@sigstore/sign@5.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^5.0.0", "@sigstore/core": "^4.0.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^16.0.0", "proc-log": "^7.0.0" } }, "sha512-DSFivqz9/i5AkwZ5fq0YdjaJlc4o1WeS2Zffon0kqtChx0vy4W9NOjkEet9bF2vkzOufX72eVH8kZBIGtcBp1w=="], + + "@sigstore/tuf": ["@sigstore/tuf@5.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^6.0.0" } }, "sha512-Zyqg9tcHps3uRAlKHLNmsW4ohsUZAjb9G+31r7lg0ICh/JOcadzmJsIRdjKljlRHpaR0K4aJ2kXXIdywdcdMlA=="], + + "@sigstore/verify": ["@sigstore/verify@4.1.2", "", { "dependencies": { "@sigstore/bundle": "^5.0.0", "@sigstore/core": "^4.0.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-BfD9eLrz3A/DG58aSgfgZYmIR6V9Yw96QVN/frtu2bEH7ctSTk2TDvHU12un3JsDPBTqTNkqkIkucjxljpOFqQ=="], + "@stablelib/aead": ["@stablelib/aead@2.0.0", "", {}, "sha512-U/RMANRxbT/ahIpYsPSiFwDFNjADHdnCFfmo09MO1ai2XmerPAOPtMl0qmX7XVvygnACC6ijKDyHBoT2rGyElg=="], "@stablelib/base64": ["@stablelib/base64@2.0.1", "", {}, "sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ=="], @@ -287,6 +308,10 @@ "@stablelib/wipe": ["@stablelib/wipe@2.0.1", "", {}, "sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg=="], + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@5.0.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.2.1" } }, "sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -351,6 +376,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -375,6 +402,8 @@ "bun-types": ["bun-types@1.1.34", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-br5QygTEL/TwB4uQOb96Ky22j4Gq2WxWH/8Oqv20fk5HagwKXo/akB+LiYgSfzexCt6kkcUaVm+bKiPl71xPvw=="], + "cacache": ["cacache@21.0.1", "", { "dependencies": { "@npmcli/fs": "^6.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^14.0.0" } }, "sha512-pTwz/uj3Jyp6WXdJ6fWhR+7LVxVs6RyroQSn7KJwHsSxXuyGSp0pcMVcwSwTpCFq1X2YG8QBe0W+vN+cr0SwzA=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], @@ -461,12 +490,16 @@ "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], @@ -479,6 +512,14 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], + + "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -487,6 +528,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -531,8 +574,24 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "make-fetch-happen": ["make-fetch-happen@16.0.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^5.0.0", "@npmcli/redact": "^5.0.0", "cacache": "^21.0.0", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^6.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^7.0.0", "ssri": "^14.0.0" } }, "sha512-uUv1yxHzaKVVEPfcFeGSNov/Cehjv08ovlY8ImTljgL7Q+SiA0dAYLQ6SYVa2kkKqNj4Y3aZEI7xv2teadie0A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@6.0.0", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-AWI8bKapGmgx/J0E6IGYSKj8TiHebZkmKWSs8raPSw8KXwgEAJ+Bw3+LSdXHR6T/RHKAWCOYk2MiLrYluaUU6w=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -543,6 +602,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], @@ -553,6 +614,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.6", "", {}, "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -563,6 +626,8 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -577,6 +642,10 @@ "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + "proc-log": ["proc-log@7.0.0", "", {}, "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg=="], + + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -599,18 +668,30 @@ "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "sigstore": ["sigstore@5.0.0", "", { "dependencies": { "@sigstore/bundle": "^5.0.0", "@sigstore/core": "^4.0.0", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^5.0.0", "@sigstore/tuf": "^5.0.0", "@sigstore/verify": "^4.0.0" } }, "sha512-hJqJfoG/e4qFQaauQL00c6J6FrHLBGKtkFvW3JbTSIEFOhLrSjdSM/gWd/yUOfYo/gsERehTXGC1VZWX+9X4Dg=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@10.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "ssri": ["ssri@14.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-jQxKI0yx0ZnTKrqjKkLDV2DXkBQn3k49JVmVqDGcDwKDtGDbImD/GXsq04KD0VVzCQQ9wZJYal3RwR1GzWTSow=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -627,6 +708,8 @@ "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "tuf-js": ["tuf-js@6.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -675,12 +758,18 @@ "@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "@npmcli/fs/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@rushstack/node-core-library/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@tufjs/models/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -691,26 +780,48 @@ "@vue/language-core/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "cacache/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@microsoft/api-extractor/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@tufjs/models/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=="], "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "@microsoft/api-extractor/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@tufjs/models/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/sdk/docs/PLATFORM.md b/sdk/docs/PLATFORM.md index 5d94d5f7c..56ea150e0 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. The complete PCR0/PCR1/PCR2 tuple must match the SDK's embedded, Sigstore-verified release snapshot; callers cannot add custom roots or runtime history URLs. +- `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/flake.lock b/sdk/flake.lock index 1ba62004d..0ca46c9ca 100644 --- a/sdk/flake.lock +++ b/sdk/flake.lock @@ -55,7 +55,8 @@ "bun-nixpkgs": "bun-nixpkgs", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" + "rust-overlay": "rust-overlay", + "sigstore-nixpkgs": "sigstore-nixpkgs" } }, "rust-overlay": { @@ -78,6 +79,22 @@ "type": "github" } }, + "sigstore-nixpkgs": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/sdk/flake.nix b/sdk/flake.nix index 7f92c38e5..9935af454 100644 --- a/sdk/flake.nix +++ b/sdk/flake.nix @@ -6,6 +6,8 @@ # Keep Bun aligned with package.json and CI without advancing the SDK's # older Node/Rust/system package set. Update all three pins together. bun-nixpkgs.url = "github:NixOS/nixpkgs/5912c1772a44e31bf1c63c0390b90501e5026886"; + # Keep Sigstore's newer Node requirement isolated from the SDK toolchain. + sigstore-nixpkgs.url = "github:NixOS/nixpkgs/241313f4e8e508cb9b13278c2b0fa25b9ca27163"; flake-utils.url = "github:numtide/flake-utils"; rust-overlay = { url = "github:oxalica/rust-overlay"; @@ -13,14 +15,40 @@ }; }; - outputs = { self, nixpkgs, bun-nixpkgs, flake-utils, rust-overlay }: + outputs = { self, nixpkgs, bun-nixpkgs, sigstore-nixpkgs, flake-utils, rust-overlay }: flake-utils.lib.eachDefaultSystem (system: let overlays = [ rust-overlay.overlays.default ]; pkgs = import nixpkgs { inherit system overlays; }; bunPkgs = import bun-nixpkgs { inherit system; }; + sigstorePkgs = import sigstore-nixpkgs { inherit system; }; sdkBun = assert bunPkgs.bun.version == "1.3.5"; bunPkgs.bun; + cosignPlatforms = { + x86_64-linux = "linux-amd64"; + aarch64-linux = "linux-arm64"; + x86_64-darwin = "darwin-amd64"; + aarch64-darwin = "darwin-arm64"; + }; + cosignHashes = { + x86_64-linux = "sha256-92Iu088i5V4a5jd8CAl5/3eiLamYHBHfIiouREmR588="; + aarch64-linux = "sha256-kOeuC139YPIIFrUsASrd9/wFXrzHvqTOgcQoyoUYwwI="; + x86_64-darwin = "sha256-rNGA+LAVviUkDKM6vuih5WTrZc3xo87kclRW0tzrfaY="; + aarch64-darwin = "sha256-3sHD+AIyCxnC+88tx7z7PyWOHBgaBGwjoaB0vfky8Qo="; + }; + cosign_3_1_2 = pkgs.stdenvNoCC.mkDerivation { + pname = "cosign"; + version = "3.1.2"; + src = pkgs.fetchurl { + url = "https://github.com/sigstore/cosign/releases/download/v3.1.2/cosign-${cosignPlatforms.${system}}"; + hash = cosignHashes.${system}; + }; + dontUnpack = true; + installPhase = '' + install -Dm755 "$src" "$out/bin/cosign" + ''; + }; + # Try to use rust-toolchain.toml if it exists, otherwise use stable rust = if builtins.pathExists ./rust-toolchain.toml then pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml @@ -29,7 +57,8 @@ commonInputs = with pkgs; [ # TypeScript/JavaScript tooling sdkBun - nodejs_20 + sigstorePkgs.nodejs + cosign_3_1_2 nodePackages.typescript nodePackages.typescript-language-server diff --git a/sdk/package.json b/sdk/package.json index 0d859e4e8..fbcbae539 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -22,7 +22,9 @@ "pack": "bun run build && bun pm pack", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", - "test": "bun test --timeout 30000" + "test": "bun test --timeout 30000", + "test:trusted-release-updater": "node --test scripts/update-trusted-enclave-releases.test.mjs", + "update:trusted-releases": "node scripts/update-trusted-enclave-releases.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" @@ -49,6 +51,7 @@ "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", + "sigstore": "5.0.0", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 63fdd3b75..4f5a1aaf3 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -30,13 +30,13 @@ http = "1" ## 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 +55,20 @@ 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 from the SDK's offline, Sigstore-verified release +snapshot before key exchange. The convenience constructors recognize only the +SDK's exact official origins. A custom HTTPS origin must use +`new_with_attestation_policy` (or the API-key equivalent) with an explicit +`TrustedReleasePolicy`; unknown remote origins never inherit production trust. + +The checked-in snapshot is intentionally empty until a tagged backend release +is verified and imported. In that staging state, real handshakes return +`UnreleasedAttestationPolicy` rather than falling back to GitHub PCR histories. +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. ## Inference APIs @@ -235,12 +213,12 @@ 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_OPEN_SECRET_ATTESTATION_ENVIRONMENT=prod 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. +Production is the default when `VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT` is omitted. +Set it to `dev` when the configured URL is a hosted development enclave. Run tests: ```bash diff --git a/sdk/rust/assets/trusted_enclave_releases.generated.json b/sdk/rust/assets/trusted_enclave_releases.generated.json new file mode 100644 index 000000000..4383ab52d --- /dev/null +++ b/sdk/rust/assets/trusted_enclave_releases.generated.json @@ -0,0 +1,17 @@ +{ + "policy": { + "oidcIssuer": "https://token.actions.githubusercontent.com", + "sourceRepository": "OpenSecretCloud/opensecret", + "sourceRepositoryId": 921901924, + "sourceRepositoryOwnerId": 185423582, + "workflow": { + "environment": "production-release", + "name": "Nitro EIF Release", + "path": ".github/workflows/release-nitro-eif.yml", + "trigger": "workflow_dispatch" + } + }, + "releases": [], + "schema": "https://opensecret.cloud/sdk/trusted-enclave-releases/v1", + "snapshotId": "f5caf5bcb6abcdae2bac8cde92ce2d3722afc65c9e7bd39c9c5a1f2ad7780052" +} diff --git a/sdk/rust/src/attestation.rs b/sdk/rust/src/attestation.rs index 281751cb1..01d5cf795 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. +/// configured `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..f36e8ac6a 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -3,8 +3,8 @@ use crate::{ cbor::{self, Value as CborValue}, crypto::{self}, error::{Error, Result}, - pcr::{Pcr0Environment, Pcr0TrustPolicy}, session::SessionManager, + trusted_release::{AttestationEnvironment, TrustedReleasePolicy}, types::*, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -48,7 +48,7 @@ pub struct OpenSecretClient { session_manager: SessionManager, refresh_lock: Mutex<()>, use_mock_attestation: bool, - pcr0_trust_policy: Pcr0TrustPolicy, + trusted_release_policy: TrustedReleasePolicy, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -469,27 +469,66 @@ fn uses_mock_attestation(base_url: &str) -> Result { Ok(is_mock_host) } -impl OpenSecretClient { - /// Construct a client using the official production PCR0 trust roots. - pub fn new(base_url: impl Into) -> Result { - Self::new_with_pcr0_environment(base_url, Pcr0Environment::default()) +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 uses_mock_attestation(base_url)? { + // The mock path is feature-gated and bypasses this policy, but client + // construction still needs a well-formed embedded policy value. + return Ok(AttestationEnvironment::Production); + } + Err(Error::Configuration( + "No default attestation environment is defined for this origin; use an explicit trusted-release policy" + .to_string(), + )) +} - /// Construct a client using one explicit official PCR0 environment. - pub fn new_with_pcr0_environment( - base_url: impl Into, - pcr0_environment: Pcr0Environment, - ) -> Result { - Self::new_with_pcr0_trust_policy(base_url, Pcr0TrustPolicy::official_for(pcr0_environment)) +fn validate_official_origin_environment( + base_url: &str, + policy: &TrustedReleasePolicy, +) -> Result<()> { + if let Some(expected) = official_attestation_environment(base_url)? { + if policy.environment() != expected.as_str() { + return Err(Error::Configuration(format!( + "Attestation environment '{}' is not allowed for this official origin; expected '{}'", + policy.environment(), + expected.as_str() + ))); + } + } + Ok(()) +} + +impl OpenSecretClient { + /// Construct a client using the embedded trusted-release snapshot selected + /// by the exact official origin. + pub fn new(base_url: impl Into) -> Result { + let base_url = base_url.into(); + let environment = default_attestation_environment(&base_url)?; + Self::new_with_attestation_policy(base_url, TrustedReleasePolicy::embedded(environment)?) } - /// Construct a client with an explicit PCR0 trust policy. - pub fn new_with_pcr0_trust_policy( + /// Construct a client with an explicit offline trusted-release policy. + pub fn new_with_attestation_policy( base_url: impl Into, - pcr0_trust_policy: Pcr0TrustPolicy, + trusted_release_policy: TrustedReleasePolicy, ) -> Result { let base_url = base_url.into(); let use_mock = uses_mock_attestation(&base_url)?; + validate_official_origin_environment(&base_url, &trusted_release_policy)?; Ok(Self { client: Client::new(), @@ -497,36 +536,31 @@ impl OpenSecretClient { session_manager: SessionManager::new(), refresh_lock: Mutex::new(()), use_mock_attestation: use_mock, - pcr0_trust_policy, + trusted_release_policy, }) } - /// Construct an API-key client using the official production PCR0 trust roots. + /// Construct an API-key client using the embedded trusted-release snapshot + /// 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()) - } - - /// Construct an API-key client using one explicit official PCR0 environment. - pub fn new_with_api_key_and_pcr0_environment( - base_url: impl Into, - api_key: String, - pcr0_environment: Pcr0Environment, - ) -> Result { - Self::new_with_api_key_and_pcr0_trust_policy( + let base_url = base_url.into(); + let environment = default_attestation_environment(&base_url)?; + Self::new_with_api_key_and_attestation_policy( base_url, api_key, - Pcr0TrustPolicy::official_for(pcr0_environment), + TrustedReleasePolicy::embedded(environment)?, ) } - /// 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 an explicit offline trusted-release policy. + pub fn new_with_api_key_and_attestation_policy( base_url: impl Into, api_key: String, - pcr0_trust_policy: Pcr0TrustPolicy, + trusted_release_policy: TrustedReleasePolicy, ) -> Result { let base_url = base_url.into(); let use_mock = uses_mock_attestation(&base_url)?; + validate_official_origin_environment(&base_url, &trusted_release_policy)?; Ok(Self { client: Client::new(), @@ -534,7 +568,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_policy, }) } @@ -569,17 +603,14 @@ 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. 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?; + self.trusted_release_policy.verify_attestation(&doc)?; self.establish_session_from_document(nonce, doc).await } @@ -3109,7 +3140,7 @@ mod tests { } #[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")) @@ -3119,9 +3150,9 @@ mod tests { .await; let production_policy = - Pcr0TrustPolicy::official_for(Pcr0Environment::Production).without_remote_history(); + TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); let client = - OpenSecretClient::new_with_pcr0_trust_policy(mock_server.uri(), production_policy) + OpenSecretClient::new_with_attestation_policy(mock_server.uri(), production_policy) .unwrap(); let document = synthetic_verified_attestation(DEVELOPMENT_PCR0); let nonce = Uuid::new_v4().to_string(); @@ -3131,35 +3162,7 @@ mod tests { .await .unwrap_err(); - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - assert!(client.get_session_id().unwrap().is_none()); - mock_server.verify().await; - } - - #[tokio::test] - async fn development_environment_accepts_development_pcr0_before_key_exchange() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .respond_with(ResponseTemplate::new(500)) - .expect(1) - .mount(&mock_server) - .await; - - let client = OpenSecretClient::new_with_pcr0_environment( - mock_server.uri(), - Pcr0Environment::Development, - ) - .unwrap(); - let document = synthetic_verified_attestation(DEVELOPMENT_PCR0); - let nonce = Uuid::new_v4().to_string(); - - let error = client - .establish_session_from_verified_attestation(&nonce, document) - .await - .unwrap_err(); - - assert!(matches!(error, Error::Api { status: 500, .. })); + assert!(matches!(error, Error::UnreleasedAttestationPolicy { .. })); assert!(client.get_session_id().unwrap().is_none()); mock_server.verify().await; } @@ -3171,7 +3174,9 @@ mod tests { "https://example.com/localhost", "https://example.com/127.0.0.1", ] { - let client = OpenSecretClient::new(url).unwrap(); + let policy = + TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); + let client = OpenSecretClient::new_with_attestation_policy(url, policy).unwrap(); assert!(!client.use_mock_attestation, "unexpected mock URL: {url}"); } @@ -3215,8 +3220,10 @@ mod tests { assert!(client.unwrap().use_mock_attestation); } else { assert!(client.is_err()); + let policy = + TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); assert!( - !OpenSecretClient::new("https://10.0.2.2:3000") + !OpenSecretClient::new_with_attestation_policy("https://10.0.2.2:3000", policy) .unwrap() .use_mock_attestation ); diff --git a/sdk/rust/src/error.rs b/sdk/rust/src/error.rs index 97716213b..9325df79f 100644 --- a/sdk/rust/src/error.rs +++ b/sdk/rust/src/error.rs @@ -17,6 +17,14 @@ 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("Session error: {0}")] Session(String), diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 4dd2ee9ed..a5838aba2 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -3,13 +3,13 @@ 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 push::*; +pub use trusted_release::{AttestationEnvironment, 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..136e93d16 --- /dev/null +++ b/sdk/rust/src/trusted_release.rs @@ -0,0 +1,998 @@ +//! Offline trust policy for OpenSecret Nitro enclave releases. +//! +//! The generated snapshot embedded by this module is an output of the SDK's +//! Sigstore verification/update tool. Runtime clients never fetch release +//! metadata from GitHub or query Rekor. They accept an attestation only when +//! its complete PCR0/PCR1/PCR2 tuple occurs in the snapshot for the explicitly +//! selected environment. + +use crate::{ + attestation::AttestationDocument, + error::{Error, Result}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +const SNAPSHOT_SCHEMA: &str = "https://opensecret.cloud/sdk/trusted-enclave-releases/v1"; +const MANIFEST_SCHEMA: &str = "https://opensecret.cloud/attestations/nitro-eif-release/v1"; +const EXPECTED_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; +const EXPECTED_SOURCE_REPOSITORY: &str = "OpenSecretCloud/opensecret"; +const EXPECTED_SOURCE_REPOSITORY_ID: u64 = 921_901_924; +const EXPECTED_SOURCE_REPOSITORY_OWNER_ID: u64 = 185_423_582; +const EXPECTED_WORKFLOW_PATH: &str = ".github/workflows/release-nitro-eif.yml"; +const EXPECTED_WORKFLOW_NAME: &str = "Nitro EIF Release"; +const EXPECTED_WORKFLOW_TRIGGER: &str = "workflow_dispatch"; +const EXPECTED_WORKFLOW_ENVIRONMENT: &str = "production-release"; +const EXPECTED_EIF_MEDIA_TYPE: &str = "application/vnd.aws.nitro.eif"; +const SHA256_HEX_LEN: usize = 64; +const SHA384_HEX_LEN: usize = 96; +const SHA384_BYTES_LEN: usize = 48; + +const EMBEDDED_RELEASE_SNAPSHOT: &str = + include_str!("../assets/trusted_enclave_releases.generated.json"); + +/// Signed release environment authorized by an attestation policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttestationEnvironment { + Production, + Development, +} + +impl AttestationEnvironment { + pub const fn as_str(self) -> &'static str { + match self { + Self::Production => "prod", + Self::Development => "dev", + } + } +} + +/// A validated, immutable set of trusted enclave measurements for one +/// deployment environment. +/// +/// Constructing a custom policy is deliberately explicit: callers must provide +/// a snapshot in the same strict format as the generated production asset and +/// select the environment it is allowed to authorize. +#[derive(Clone, Debug)] +pub struct TrustedReleasePolicy { + expected_environment: String, + snapshot_id: String, + releases: Vec, +} + +#[derive(Clone, Debug)] +struct TrustedRelease { + tag: String, + pcr0: [u8; SHA384_BYTES_LEN], + pcr1: [u8; SHA384_BYTES_LEN], + pcr2: [u8; SHA384_BYTES_LEN], +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReleaseSnapshot { + schema: String, + policy: SnapshotPolicy, + snapshot_id: String, + releases: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotPolicy { + oidc_issuer: String, + source_repository: String, + source_repository_id: u64, + source_repository_owner_id: u64, + workflow: SnapshotWorkflow, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotWorkflow { + path: String, + name: String, + trigger: String, + environment: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotRelease { + manifest_sha256: String, + bundle_sha256: String, + signer: SnapshotSigner, + transparency_log: SnapshotTransparencyLog, + manifest: ReleaseManifest, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotSigner { + oidc_issuer: String, + identity: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReleaseManifest { + schema: String, + environment: String, + source: SnapshotSource, + release: SnapshotReleaseIdentity, + artifact: SnapshotArtifact, + measurements: SnapshotMeasurements, + build: SnapshotBuild, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotSource { + repository: String, + repository_id: u64, + owner_id: u64, + r#ref: String, + commit: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotReleaseIdentity { + tag: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotArtifact { + name: String, + media_type: String, + sha256: String, + size: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotMeasurements { + algorithm: String, + required_pcrs: [u8; 3], + pcrs: SnapshotPcrs, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SnapshotPcrs { + #[serde(rename = "0")] + pcr0: String, + #[serde(rename = "1")] + pcr1: String, + #[serde(rename = "2")] + pcr2: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotTransparencyLog { + log_index: String, + log_id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SnapshotBuild { + system: String, + flake_lock_sha256: String, + derivation: String, + workflow_run: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SnapshotIdInput<'a> { + schema: &'a str, + policy: &'a SnapshotPolicy, + releases: &'a [SnapshotRelease], +} + +impl TrustedReleasePolicy { + /// Loads the embedded snapshot and selects exactly one environment. + pub fn embedded(environment: AttestationEnvironment) -> Result { + let policy = Self::from_snapshot_json(EMBEDDED_RELEASE_SNAPSHOT, environment.as_str())?; + + let raw: ReleaseSnapshot = serde_json::from_str(EMBEDDED_RELEASE_SNAPSHOT) + .map_err(|error| Error::TrustedReleasePolicy(error.to_string()))?; + validate_official_policy(&raw.policy)?; + + Ok(policy) + } + + /// Loads the build-time snapshot for the production OpenSecret service. + /// + /// An empty, well-formed snapshot is accepted here so SDK artifacts can be + /// prepared before the first signed release is published. Verification + /// still fails closed with [`Error::UnreleasedAttestationPolicy`]. + pub fn embedded_production() -> Result { + Self::embedded(AttestationEnvironment::Production) + } + + /// Loads the build-time snapshot for an explicitly selected development + /// OpenSecret enclave. + pub fn embedded_development() -> Result { + Self::embedded(AttestationEnvironment::Development) + } + + /// Validates a generated snapshot and binds it to exactly one environment. + /// + /// This is intended for explicitly configured development or self-hosted + /// deployments. It does not weaken Nitro document verification. + pub fn from_snapshot_json( + snapshot_json: &str, + expected_environment: impl Into, + ) -> Result { + let expected_environment = expected_environment.into(); + validate_environment(&expected_environment)?; + + let raw: ReleaseSnapshot = serde_json::from_str(snapshot_json) + .map_err(|error| Error::TrustedReleasePolicy(error.to_string()))?; + if raw.schema != SNAPSHOT_SCHEMA { + return Err(policy_error(format!( + "unsupported snapshot schema '{}'", + raw.schema + ))); + } + validate_hex("snapshotId", &raw.snapshot_id, SHA256_HEX_LEN)?; + validate_snapshot_id(&raw)?; + validate_nonempty("policy.oidcIssuer", &raw.policy.oidc_issuer)?; + validate_nonempty("policy.sourceRepository", &raw.policy.source_repository)?; + if raw.policy.source_repository_id == 0 { + return Err(policy_error( + "policy.sourceRepositoryId must be greater than zero", + )); + } + if raw.policy.source_repository_owner_id == 0 { + return Err(policy_error( + "policy.sourceRepositoryOwnerId must be greater than zero", + )); + } + validate_workflow_path(&raw.policy.workflow.path)?; + validate_nonempty("policy.workflow.name", &raw.policy.workflow.name)?; + validate_nonempty("policy.workflow.trigger", &raw.policy.workflow.trigger)?; + validate_nonempty( + "policy.workflow.environment", + &raw.policy.workflow.environment, + )?; + + let mut releases = Vec::new(); + let mut release_keys = HashSet::new(); + let mut manifest_hashes = HashSet::new(); + for release in raw.releases { + validate_release(&release, &raw.policy)?; + let release_key = format!( + "{}:{}", + release.manifest.environment, release.manifest.release.tag + ); + if !release_keys.insert(release_key.clone()) { + return Err(policy_error(format!( + "duplicate trusted release entry '{release_key}'" + ))); + } + if !manifest_hashes.insert(release.manifest_sha256.clone()) { + return Err(policy_error(format!( + "duplicate trusted release manifest '{}'", + release.manifest_sha256 + ))); + } + if release.manifest.environment != expected_environment { + continue; + } + + releases.push(TrustedRelease { + tag: release.manifest.release.tag, + pcr0: decode_pcr( + "manifest.measurements.pcrs.0", + &release.manifest.measurements.pcrs.pcr0, + )?, + pcr1: decode_pcr( + "manifest.measurements.pcrs.1", + &release.manifest.measurements.pcrs.pcr1, + )?, + pcr2: decode_pcr( + "manifest.measurements.pcrs.2", + &release.manifest.measurements.pcrs.pcr2, + )?, + }); + } + + Ok(Self { + expected_environment, + snapshot_id: raw.snapshot_id, + releases, + }) + } + + /// The environment this policy is permitted to authorize. + pub fn environment(&self) -> &str { + &self.expected_environment + } + + /// Stable identifier of the generated release snapshot. + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + + /// Verifies the complete PCR0/PCR1/PCR2 tuple atomically. + pub fn verify_attestation(&self, document: &AttestationDocument) -> Result<()> { + if self.releases.is_empty() { + return Err(Error::UnreleasedAttestationPolicy { + environment: self.expected_environment.clone(), + }); + } + 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 release_tags = self + .releases + .iter() + .map(|release| release.tag.as_str()) + .collect::>() + .join(", "); + Err(Error::AttestationVerificationFailed(format!( + "PCR0/PCR1/PCR2 tuple is not present in trusted snapshot {} for environment '{}' (published releases: {})", + self.snapshot_id, self.expected_environment, release_tags + ))) + } +} + +fn validate_snapshot_id(snapshot: &ReleaseSnapshot) -> Result<()> { + let input = SnapshotIdInput { + schema: &snapshot.schema, + policy: &snapshot.policy, + releases: &snapshot.releases, + }; + let actual = hex::encode(Sha256::digest(canonical_json_bytes(&input)?)); + if snapshot.snapshot_id != actual { + return Err(policy_error(format!( + "snapshotId '{}' does not match snapshot contents '{}'", + snapshot.snapshot_id, actual + ))); + } + Ok(()) +} + +fn canonical_json_bytes(value: &T) -> Result> { + let value = serde_json::to_value(value) + .map_err(|error| policy_error(format!("failed to serialize trusted policy: {error}")))?; + let mut bytes = serde_json::to_vec_pretty(&sort_json(value)) + .map_err(|error| policy_error(format!("failed to serialize trusted policy: {error}")))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn sort_json(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.into_iter().map(sort_json).collect()) + } + serde_json::Value::Object(values) => { + let mut entries = values.into_iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = serde_json::Map::new(); + for (key, value) in entries { + sorted.insert(key, sort_json(value)); + } + serde_json::Value::Object(sorted) + } + value => value, + } +} + +fn validate_official_policy(policy: &SnapshotPolicy) -> Result<()> { + for (field, actual, expected) in [ + ( + "policy.oidcIssuer", + policy.oidc_issuer.clone(), + EXPECTED_OIDC_ISSUER.to_string(), + ), + ( + "policy.sourceRepository", + policy.source_repository.clone(), + EXPECTED_SOURCE_REPOSITORY.to_string(), + ), + ( + "policy.sourceRepositoryId", + policy.source_repository_id.to_string(), + EXPECTED_SOURCE_REPOSITORY_ID.to_string(), + ), + ( + "policy.sourceRepositoryOwnerId", + policy.source_repository_owner_id.to_string(), + EXPECTED_SOURCE_REPOSITORY_OWNER_ID.to_string(), + ), + ( + "policy.workflow.path", + policy.workflow.path.clone(), + EXPECTED_WORKFLOW_PATH.to_string(), + ), + ( + "policy.workflow.name", + policy.workflow.name.clone(), + EXPECTED_WORKFLOW_NAME.to_string(), + ), + ( + "policy.workflow.trigger", + policy.workflow.trigger.clone(), + EXPECTED_WORKFLOW_TRIGGER.to_string(), + ), + ( + "policy.workflow.environment", + policy.workflow.environment.clone(), + EXPECTED_WORKFLOW_ENVIRONMENT.to_string(), + ), + ] { + if actual != expected { + return Err(policy_error(format!( + "{field} must be '{expected}', got '{actual}'" + ))); + } + } + Ok(()) +} + +fn validate_release(release: &SnapshotRelease, policy: &SnapshotPolicy) -> Result<()> { + validate_hex( + "release.manifestSha256", + &release.manifest_sha256, + SHA256_HEX_LEN, + )?; + let canonical_manifest = canonical_json_bytes(&release.manifest)?; + let actual_manifest_sha256 = hex::encode(Sha256::digest(&canonical_manifest)); + if release.manifest_sha256 != actual_manifest_sha256 { + return Err(policy_error(format!( + "release manifestSha256 '{}' does not match embedded manifest '{}'", + release.manifest_sha256, actual_manifest_sha256 + ))); + } + validate_hex( + "release.bundleSha256", + &release.bundle_sha256, + SHA256_HEX_LEN, + )?; + if release.signer.oidc_issuer != policy.oidc_issuer { + return Err(policy_error(format!( + "release signer issuer '{}' does not match policy issuer '{}'", + release.signer.oidc_issuer, policy.oidc_issuer + ))); + } + + let manifest = &release.manifest; + if manifest.schema != MANIFEST_SCHEMA { + return Err(policy_error(format!( + "unsupported release manifest schema '{}'", + manifest.schema + ))); + } + validate_environment(&manifest.environment)?; + if manifest.source.repository != policy.source_repository { + return Err(policy_error(format!( + "release source repository '{}' does not match policy repository '{}'", + manifest.source.repository, policy.source_repository + ))); + } + if manifest.source.repository_id != policy.source_repository_id + || manifest.source.owner_id != policy.source_repository_owner_id + { + return Err(policy_error( + "release source repository IDs do not match snapshot policy", + )); + } + validate_hex( + "release.manifest.source.commit", + &manifest.source.commit, + 40, + )?; + validate_release_tag(&manifest.release.tag)?; + let expected_ref = format!("refs/tags/{}", manifest.release.tag); + if manifest.source.r#ref != expected_ref { + return Err(policy_error(format!( + "release source ref '{}' does not match tag '{}'", + manifest.source.r#ref, manifest.release.tag + ))); + } + let expected_identity = format!( + "https://github.com/{}/{}@{}", + policy.source_repository, policy.workflow.path, manifest.source.r#ref + ); + if release.signer.identity != expected_identity { + return Err(policy_error(format!( + "release signer identity '{}' does not match '{}'", + release.signer.identity, expected_identity + ))); + } + + let expected_artifact_name = format!( + "opensecret-{}-{}.eif", + manifest.release.tag, manifest.environment + ); + if manifest.artifact.name != expected_artifact_name { + return Err(policy_error(format!( + "release artifact name '{}' does not match '{}'", + manifest.artifact.name, expected_artifact_name + ))); + } + validate_artifact_name(&manifest.artifact.name)?; + if manifest.artifact.media_type != EXPECTED_EIF_MEDIA_TYPE { + return Err(policy_error(format!( + "release artifact media type must be '{EXPECTED_EIF_MEDIA_TYPE}'" + ))); + } + if manifest.artifact.size == 0 { + return Err(policy_error( + "release artifact size must be greater than zero", + )); + } + validate_hex( + "release.artifact.sha256", + &manifest.artifact.sha256, + SHA256_HEX_LEN, + )?; + if manifest.measurements.algorithm != "sha384" { + return Err(policy_error( + "release measurement algorithm must be 'sha384'", + )); + } + if manifest.measurements.required_pcrs != [0, 1, 2] { + return Err(policy_error( + "release requiredPcrs must be exactly [0, 1, 2]", + )); + } + decode_pcr( + "release.measurements.pcrs.0", + &manifest.measurements.pcrs.pcr0, + )?; + decode_pcr( + "release.measurements.pcrs.1", + &manifest.measurements.pcrs.pcr1, + )?; + decode_pcr( + "release.measurements.pcrs.2", + &manifest.measurements.pcrs.pcr2, + )?; + + if release.transparency_log.log_index.is_empty() + || !release + .transparency_log + .log_index + .bytes() + .all(|byte| byte.is_ascii_digit()) + || (release.transparency_log.log_index.len() > 1 + && release.transparency_log.log_index.starts_with('0')) + { + return Err(policy_error( + "release transparency log index must be an unsigned decimal integer", + )); + } + validate_hex( + "release.transparencyLog.logId", + &release.transparency_log.log_id, + SHA256_HEX_LEN, + )?; + + if manifest.build.system != "nix" { + return Err(policy_error("release build system must be 'nix'")); + } + validate_hex( + "release.manifest.build.flakeLockSha256", + &manifest.build.flake_lock_sha256, + SHA256_HEX_LEN, + )?; + let expected_derivation = format!("eif-{}", manifest.environment); + if manifest.build.derivation != expected_derivation { + return Err(policy_error(format!( + "release build derivation '{}' does not match environment '{}'", + manifest.build.derivation, manifest.environment + ))); + } + validate_workflow_run(&manifest.build.workflow_run, &policy.source_repository)?; + Ok(()) +} + +fn validate_environment(environment: &str) -> Result<()> { + if matches!(environment, "prod" | "dev") { + Ok(()) + } else { + Err(policy_error(format!( + "unsupported attestation environment '{environment}'" + ))) + } +} + +fn validate_release_tag(tag: &str) -> Result<()> { + let Some(version) = tag.strip_prefix('v') else { + return Err(policy_error(format!( + "release tag '{tag}' is not a stable vMAJOR.MINOR.PATCH tag" + ))); + }; + let parts = version.split('.').collect::>(); + if parts.len() != 3 + || parts + .iter() + .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) + || parts + .iter() + .any(|part| part.len() > 1 && part.starts_with('0')) + { + return Err(policy_error(format!( + "release tag '{tag}' is not a stable vMAJOR.MINOR.PATCH tag" + ))); + } + Ok(()) +} + +fn validate_workflow_path(path: &str) -> Result<()> { + validate_nonempty("policy.workflow.path", path)?; + if path.starts_with('/') + || path.contains('\\') + || path.split('/').any(|component| component == "..") + || !path.starts_with(".github/workflows/") + || !(path.ends_with(".yml") || path.ends_with(".yaml")) + { + return Err(policy_error(format!( + "invalid GitHub Actions workflow path '{path}'" + ))); + } + Ok(()) +} + +fn validate_artifact_name(name: &str) -> Result<()> { + validate_nonempty("release.artifact.name", name)?; + if name == "." || name == ".." || name.contains('/') || name.contains('\\') { + return Err(policy_error(format!( + "release artifact name '{name}' must be a file name" + ))); + } + Ok(()) +} + +fn validate_workflow_run(value: &str, repository: &str) -> Result<()> { + let url = reqwest::Url::parse(value) + .map_err(|error| policy_error(format!("invalid build workflowRun URL: {error}")))?; + let expected_prefix = format!("/{repository}/actions/runs/"); + let run_id = url + .path() + .strip_prefix(&expected_prefix) + .unwrap_or_default(); + let run_parts = run_id.split('/').collect::>(); + let valid_run_path = matches!( + run_parts.as_slice(), + [run, "attempts", attempt] + if is_positive_decimal(run) && is_positive_decimal(attempt) + ); + if url.scheme() != "https" + || url.host_str() != Some("github.com") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !valid_run_path + { + return Err(policy_error(format!( + "build workflowRun must be an exact GitHub Actions run-attempt URL for '{repository}'" + ))); + } + Ok(()) +} + +fn is_positive_decimal(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) && !value.starts_with('0') +} + +fn validate_nonempty(field: &str, value: &str) -> Result<()> { + if value.is_empty() || value.trim() != value { + Err(policy_error(format!("{field} must be a non-empty string"))) + } else { + Ok(()) + } +} + +fn validate_hex(field: &str, value: &str, expected_len: usize) -> Result<()> { + if value.len() != expected_len + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(policy_error(format!( + "{field} must be exactly {expected_len} 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 exactly {SHA384_BYTES_LEN} bytes" + )) + }) +} + +fn attestation_pcr(document: &AttestationDocument, index: usize) -> Result<[u8; SHA384_BYTES_LEN]> { + let value = document + .pcrs + .get(&index) + .ok_or_else(|| Error::AttestationVerificationFailed(format!("PCR{index} missing")))?; + value.as_slice().try_into().map_err(|_| { + Error::AttestationVerificationFailed(format!( + "PCR{index} must be exactly {SHA384_BYTES_LEN} bytes" + )) + }) +} + +fn policy_error(message: impl Into) -> Error { + Error::TrustedReleasePolicy(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn snapshot(releases: &str) -> String { + let releases: serde_json::Value = serde_json::from_str(releases).unwrap(); + let mut value = serde_json::json!({ + "schema": SNAPSHOT_SCHEMA, + "policy": { + "oidcIssuer": EXPECTED_OIDC_ISSUER, + "sourceRepository": EXPECTED_SOURCE_REPOSITORY, + "sourceRepositoryId": EXPECTED_SOURCE_REPOSITORY_ID, + "sourceRepositoryOwnerId": EXPECTED_SOURCE_REPOSITORY_OWNER_ID, + "workflow": { + "path": EXPECTED_WORKFLOW_PATH, + "name": EXPECTED_WORKFLOW_NAME, + "trigger": EXPECTED_WORKFLOW_TRIGGER, + "environment": EXPECTED_WORKFLOW_ENVIRONMENT + } + }, + "releases": releases + }); + let snapshot_id = hex::encode(Sha256::digest(canonical_json_bytes(&value).unwrap())); + value.as_object_mut().unwrap().insert( + "snapshotId".to_string(), + serde_json::Value::String(snapshot_id), + ); + String::from_utf8(canonical_json_bytes(&value).unwrap()).unwrap() + } + + fn release(tag: &str, environment: &str, values: [u8; 3]) -> String { + let mut value: serde_json::Value = serde_json::from_str(&format!( + r#"{{ + "manifestSha256": "{sha256}", + "bundleSha256": "{sha256}", + "signer": {{ + "oidcIssuer": "{EXPECTED_OIDC_ISSUER}", + "identity": "https://github.com/{EXPECTED_SOURCE_REPOSITORY}/{EXPECTED_WORKFLOW_PATH}@refs/tags/{tag}" + }}, + "transparencyLog": {{ + "logIndex": "42", + "logId": "{sha256}" + }}, + "manifest": {{ + "schema": "{MANIFEST_SCHEMA}", + "environment": "{environment}", + "source": {{ + "repository": "{EXPECTED_SOURCE_REPOSITORY}", + "repositoryId": {EXPECTED_SOURCE_REPOSITORY_ID}, + "ownerId": {EXPECTED_SOURCE_REPOSITORY_OWNER_ID}, + "ref": "refs/tags/{tag}", + "commit": "{commit}" + }}, + "release": {{ "tag": "{tag}" }}, + "artifact": {{ + "name": "opensecret-{tag}-{environment}.eif", + "mediaType": "{EXPECTED_EIF_MEDIA_TYPE}", + "sha256": "{sha256}", + "size": 123 + }}, + "measurements": {{ + "algorithm": "sha384", + "requiredPcrs": [0, 1, 2], + "pcrs": {{ + "0": "{pcr0}", + "1": "{pcr1}", + "2": "{pcr2}" + }} + }}, + "build": {{ + "system": "nix", + "flakeLockSha256": "{sha256}", + "derivation": "eif-{environment}", + "workflowRun": "https://github.com/{EXPECTED_SOURCE_REPOSITORY}/actions/runs/123456789/attempts/1" + }} + }} +}}"#, + sha256 = "b".repeat(SHA256_HEX_LEN), + commit = "c".repeat(40), + pcr0 = hex::encode([values[0]; SHA384_BYTES_LEN]), + pcr1 = hex::encode([values[1]; SHA384_BYTES_LEN]), + pcr2 = hex::encode([values[2]; SHA384_BYTES_LEN]), + )) + .unwrap(); + let manifest_sha256 = hex::encode(Sha256::digest( + canonical_json_bytes(&value["manifest"]).unwrap(), + )); + value["manifestSha256"] = serde_json::Value::String(manifest_sha256); + String::from_utf8(canonical_json_bytes(&value).unwrap()).unwrap() + } + + fn rehash_release(release: String) -> String { + let mut value: serde_json::Value = serde_json::from_str(&release).unwrap(); + let manifest_sha256 = hex::encode(Sha256::digest( + canonical_json_bytes(&value["manifest"]).unwrap(), + )); + value["manifestSha256"] = serde_json::Value::String(manifest_sha256); + String::from_utf8(canonical_json_bytes(&value).unwrap()).unwrap() + } + + fn document(values: [u8; 3]) -> AttestationDocument { + AttestationDocument { + module_id: "test".to_string(), + timestamp: 0, + digest: "SHA384".to_string(), + pcrs: HashMap::from([ + (0, vec![values[0]; SHA384_BYTES_LEN]), + (1, vec![values[1]; SHA384_BYTES_LEN]), + (2, vec![values[2]; SHA384_BYTES_LEN]), + ]), + certificate: Vec::new(), + cabundle: Vec::new(), + public_key: None, + user_data: None, + nonce: None, + } + } + + #[test] + fn accepts_complete_tuple_from_one_release() { + let policy = TrustedReleasePolicy::from_snapshot_json( + &snapshot(&format!("[{}]", release("v1.2.3", "prod", [1, 2, 3]))), + "prod", + ) + .unwrap(); + + policy.verify_attestation(&document([1, 2, 3])).unwrap(); + } + + #[test] + fn rejects_pcrs_mixed_across_releases() { + let releases = format!( + "[{},{}]", + release("v1.2.3", "prod", [1, 2, 3]), + release("v1.2.4", "prod", [4, 5, 6]) + ); + let policy = + TrustedReleasePolicy::from_snapshot_json(&snapshot(&releases), "prod").unwrap(); + + let error = policy.verify_attestation(&document([1, 5, 3])).unwrap_err(); + assert!(matches!( + error, + Error::AttestationVerificationFailed(message) + if message.contains("PCR0/PCR1/PCR2 tuple") + )); + } + + #[test] + fn binds_releases_to_selected_environment() { + let releases = format!( + "[{},{}]", + release("v1.2.3", "prod", [1, 2, 3]), + release("v1.2.3", "dev", [4, 5, 6]) + ); + let policy = + TrustedReleasePolicy::from_snapshot_json(&snapshot(&releases), "prod").unwrap(); + + let error = policy.verify_attestation(&document([4, 5, 6])).unwrap_err(); + assert!(matches!(error, Error::AttestationVerificationFailed(_))); + } + + #[test] + fn empty_environment_fails_with_unreleased_policy_error() { + let policy = TrustedReleasePolicy::from_snapshot_json(&snapshot("[]"), "prod").unwrap(); + + let error = policy.verify_attestation(&document([1, 2, 3])).unwrap_err(); + assert!(matches!( + error, + Error::UnreleasedAttestationPolicy { environment } if environment == "prod" + )); + } + + #[test] + fn rejects_missing_or_wrong_length_required_pcr() { + let policy = TrustedReleasePolicy::from_snapshot_json( + &snapshot(&format!("[{}]", release("v1.2.3", "prod", [1, 2, 3]))), + "prod", + ) + .unwrap(); + let mut missing = document([1, 2, 3]); + missing.pcrs.remove(&1); + assert!(matches!( + policy.verify_attestation(&missing), + Err(Error::AttestationVerificationFailed(message)) if message == "PCR1 missing" + )); + + let mut short = document([1, 2, 3]); + short.pcrs.insert(2, vec![3; SHA384_BYTES_LEN - 1]); + assert!(matches!( + policy.verify_attestation(&short), + Err(Error::AttestationVerificationFailed(message)) + if message.contains("PCR2 must be exactly") + )); + } + + #[test] + fn rejects_unstable_tag_and_cross_record_ref() { + let unstable = release("v1.2.3-rc.1", "prod", [1, 2, 3]); + assert!(matches!( + TrustedReleasePolicy::from_snapshot_json(&snapshot(&format!("[{unstable}]")), "prod"), + Err(Error::TrustedReleasePolicy(message)) if message.contains("stable") + )); + + let wrong_ref = rehash_release( + release("v1.2.3", "prod", [1, 2, 3]).replace("refs/tags/v1.2.3", "refs/tags/v9.9.9"), + ); + assert!(matches!( + TrustedReleasePolicy::from_snapshot_json(&snapshot(&format!("[{wrong_ref}]")), "prod"), + Err(Error::TrustedReleasePolicy(message)) if message.contains("does not match tag") + )); + } + + #[test] + fn rejects_all_zero_release_measurement() { + let zero_pcr = rehash_release(release("v1.2.3", "prod", [1, 2, 3]).replace( + &hex::encode([1; SHA384_BYTES_LEN]), + &hex::encode([0; SHA384_BYTES_LEN]), + )); + + assert!(matches!( + TrustedReleasePolicy::from_snapshot_json(&snapshot(&format!("[{zero_pcr}]")), "prod"), + Err(Error::TrustedReleasePolicy(message)) if message.contains("must not be all zeroes") + )); + } + + #[test] + fn accepts_exact_github_run_attempt_urls_only() { + validate_workflow_run( + "https://github.com/OpenSecretCloud/opensecret/actions/runs/123/attempts/2", + EXPECTED_SOURCE_REPOSITORY, + ) + .unwrap(); + for invalid in [ + "https://github.com/OpenSecretCloud/opensecret/actions/runs/123", + "https://github.com/OpenSecretCloud/opensecret/actions/runs/123/jobs/2", + "https://github.com/OpenSecretCloud/opensecret/actions/runs/123/attempts/0", + "https://github.com/OpenSecretCloud/opensecret/actions/runs/123?attempt=2", + "https://example.com/OpenSecretCloud/opensecret/actions/runs/123", + ] { + assert!(validate_workflow_run(invalid, EXPECTED_SOURCE_REPOSITORY).is_err()); + } + } +} diff --git a/sdk/rust/tests/attestation.rs b/sdk/rust/tests/attestation.rs index e1500aa5c..760d3e25b 100644 --- a/sdk/rust/tests/attestation.rs +++ b/sdk/rust/tests/attestation.rs @@ -1,6 +1,6 @@ mod common; -use opensecret::{Error, OpenSecretClient, Pcr0Environment, Result}; +use opensecret::{AttestationEnvironment, Error, OpenSecretClient, Result, TrustedReleasePolicy}; use std::env; #[tokio::test] @@ -67,7 +67,7 @@ async fn test_attestation_handshake_hosted_selected_environment() -> Result<()> } #[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 +75,18 @@ 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 { + if common::selected_pcr0_environment()? != AttestationEnvironment::Development { println!("Skipping hosted development policy-separation test"); return Ok(()); } - let client = OpenSecretClient::new(base_url)?; - let error = client.perform_attestation_handshake().await.unwrap_err(); + let production_policy = TrustedReleasePolicy::embedded(AttestationEnvironment::Production)?; + let error = match OpenSecretClient::new_with_attestation_policy(base_url, production_policy) { + Ok(_) => panic!("production policy must not be accepted for the development origin"), + Err(error) => error, + }; - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - assert!(client.get_session_id()?.is_none()); + assert!(matches!(error, Error::Configuration(_))); Ok(()) } diff --git a/sdk/rust/tests/common/mod.rs b/sdk/rust/tests/common/mod.rs index 2928b3707..7413ea2f3 100644 --- a/sdk/rust/tests/common/mod.rs +++ b/sdk/rust/tests/common/mod.rs @@ -1,11 +1,11 @@ #![allow(dead_code)] -use opensecret::{Error, OpenSecretClient, Pcr0Environment, Result}; +use opensecret::{AttestationEnvironment, Error, OpenSecretClient, Result, TrustedReleasePolicy}; use std::env::{self, VarError}; -const PCR_ENVIRONMENT_VARIABLE: &str = "VITE_OPEN_SECRET_PCR_ENVIRONMENT"; +const PCR_ENVIRONMENT_VARIABLE: &str = "VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT"; const PCR_ENVIRONMENT_ERROR: &str = - "VITE_OPEN_SECRET_PCR_ENVIRONMENT must be either \"production\" or \"development\""; + "VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT must be either \"prod\" or \"dev\""; pub fn live_ai_enabled() -> bool { env::var("RUN_LIVE_AI").is_ok_and(|value| value == "1") @@ -13,15 +13,15 @@ pub fn live_ai_enabled() -> bool { pub fn parse_pcr0_environment( value: Option<&str>, -) -> std::result::Result { +) -> std::result::Result { match value { - None | Some("production") => Ok(Pcr0Environment::Production), - Some("development") => Ok(Pcr0Environment::Development), + None | Some("prod") => Ok(AttestationEnvironment::Production), + Some("dev") => Ok(AttestationEnvironment::Development), Some(_) => Err(PCR_ENVIRONMENT_ERROR), } } -pub fn selected_pcr0_environment() -> Result { +pub fn selected_pcr0_environment() -> Result { let configured = match env::var(PCR_ENVIRONMENT_VARIABLE) { Ok(value) => Some(value), Err(VarError::NotPresent) => None, @@ -35,16 +35,19 @@ pub fn selected_pcr0_environment() -> Result { } pub fn new_test_client(base_url: impl Into) -> Result { - OpenSecretClient::new_with_pcr0_environment(base_url, selected_pcr0_environment()?) + OpenSecretClient::new_with_attestation_policy( + base_url, + TrustedReleasePolicy::embedded(selected_pcr0_environment()?)?, + ) } pub fn new_test_client_with_api_key( base_url: impl Into, api_key: String, ) -> Result { - OpenSecretClient::new_with_api_key_and_pcr0_environment( + OpenSecretClient::new_with_api_key_and_attestation_policy( base_url, api_key, - selected_pcr0_environment()?, + TrustedReleasePolicy::embedded(selected_pcr0_environment()?)?, ) } diff --git a/sdk/rust/tests/pcr_environment.rs b/sdk/rust/tests/pcr_environment.rs index 8bf30436b..0d29af5d1 100644 --- a/sdk/rust/tests/pcr_environment.rs +++ b/sdk/rust/tests/pcr_environment.rs @@ -1,31 +1,31 @@ mod common; use common::parse_pcr0_environment; -use opensecret::Pcr0Environment; +use opensecret::AttestationEnvironment; #[test] fn pcr_environment_defaults_to_production() { assert_eq!( parse_pcr0_environment(None).unwrap(), - Pcr0Environment::Production + AttestationEnvironment::Production ); } #[test] fn pcr_environment_accepts_exact_supported_values() { assert_eq!( - parse_pcr0_environment(Some("production")).unwrap(), - Pcr0Environment::Production + parse_pcr0_environment(Some("prod")).unwrap(), + AttestationEnvironment::Production ); assert_eq!( - parse_pcr0_environment(Some("development")).unwrap(), - Pcr0Environment::Development + parse_pcr0_environment(Some("dev")).unwrap(), + AttestationEnvironment::Development ); } #[test] fn pcr_environment_rejects_empty_differently_cased_and_unknown_values() { - for value in ["", "Production", "dev", " development "] { + for value in ["", "Prod", "production", "development", " dev "] { assert!(parse_pcr0_environment(Some(value)).is_err()); } } diff --git a/sdk/scripts/update-trusted-enclave-releases.mjs b/sdk/scripts/update-trusted-enclave-releases.mjs new file mode 100644 index 000000000..d9af8fbe1 --- /dev/null +++ b/sdk/scripts/update-trusted-enclave-releases.mjs @@ -0,0 +1,656 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { verify } from "sigstore"; + +const SNAPSHOT_SCHEMA = "https://opensecret.cloud/sdk/trusted-enclave-releases/v1"; +const MANIFEST_SCHEMA = "https://opensecret.cloud/attestations/nitro-eif-release/v1"; +const BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json"; +const OIDC_ISSUER = "https://token.actions.githubusercontent.com"; +const SOURCE_REPOSITORY = "OpenSecretCloud/opensecret"; +const SOURCE_REPOSITORY_URI = `https://github.com/${SOURCE_REPOSITORY}`; +const SOURCE_REPOSITORY_ID = 921901924; +const SOURCE_REPOSITORY_OWNER_ID = 185423582; +const SOURCE_REPOSITORY_OWNER_URI = "https://github.com/OpenSecretCloud"; +const WORKFLOW_PATH = ".github/workflows/release-nitro-eif.yml"; +const WORKFLOW_NAME = "Nitro EIF Release"; +const WORKFLOW_TRIGGER = "workflow_dispatch"; +const WORKFLOW_ENVIRONMENT = "production-release"; +const REQUIRED_COSIGN_VERSION = [3, 1, 2]; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const defaultOutputs = [ + resolve(projectRoot, "src/lib/trusted-enclave-releases.generated.json"), + resolve(projectRoot, "rust/assets/trusted_enclave_releases.generated.json") +]; + +const SNAPSHOT_POLICY = { + oidcIssuer: OIDC_ISSUER, + sourceRepository: SOURCE_REPOSITORY, + sourceRepositoryId: SOURCE_REPOSITORY_ID, + sourceRepositoryOwnerId: SOURCE_REPOSITORY_OWNER_ID, + workflow: { + path: WORKFLOW_PATH, + name: WORKFLOW_NAME, + trigger: WORKFLOW_TRIGGER, + environment: WORKFLOW_ENVIRONMENT + } +}; + +function usage() { + return `Usage: + node scripts/update-trusted-enclave-releases.mjs \\ + --manifest --bundle \\ + [--manifest <...> --bundle <...>] [--cosign ] [--output ...] + +Every desired trusted release must be supplied on each run. The updater verifies +each exact manifest byte sequence with both official sigstore-js and Cosign, +then atomically rewrites the TypeScript and Rust embedded snapshots. +`; +} + +function fail(message) { + throw new Error(message); +} + +function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function assertPlainObject(value, path) { + if (!isPlainObject(value)) { + fail(`${path} must be a JSON object`); + } + return value; +} + +function assertExactKeys(value, expectedKeys, path) { + const object = assertPlainObject(value, path); + const actual = Object.keys(object).sort(); + const expected = [...expectedKeys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail(`${path} must contain exactly: ${expected.join(", ")}`); + } + return object; +} + +function assertString(value, path, pattern) { + if (typeof value !== "string" || (pattern && !pattern.test(value))) { + fail(`${path} has an invalid string value`); + } + return value; +} + +function assertInteger(value, path, { positive = false } = {}) { + if (!Number.isSafeInteger(value) || (positive && value <= 0)) { + fail(`${path} must be a ${positive ? "positive " : ""}safe integer`); + } + return value; +} + +function assertLiteral(value, expected, path) { + if (value !== expected) { + fail(`${path} must equal ${JSON.stringify(expected)}`); + } + return value; +} + +function sortJson(value) { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortJson(value[key])]) + ); + } + return value; +} + +function canonicalJson(value) { + return `${JSON.stringify(sortJson(value), null, 2)}\n`; +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function parseCanonicalManifest(rawBytes, path) { + let parsed; + try { + parsed = JSON.parse(rawBytes.toString("utf8")); + } catch (error) { + fail(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + const canonicalBytes = Buffer.from(canonicalJson(parsed)); + if (!rawBytes.equals(canonicalBytes)) { + fail( + `${path} is not canonical key-sorted two-space JSON with one trailing LF (duplicate keys are also rejected)` + ); + } + + const manifest = assertExactKeys( + parsed, + ["schema", "environment", "source", "release", "artifact", "measurements", "build"], + "manifest" + ); + assertLiteral(manifest.schema, MANIFEST_SCHEMA, "manifest.schema"); + if (manifest.environment !== "prod" && manifest.environment !== "dev") { + fail("manifest.environment must be prod or dev"); + } + + const source = assertExactKeys( + manifest.source, + ["repository", "repositoryId", "ownerId", "ref", "commit"], + "manifest.source" + ); + assertLiteral(source.repository, SOURCE_REPOSITORY, "manifest.source.repository"); + assertLiteral(source.repositoryId, SOURCE_REPOSITORY_ID, "manifest.source.repositoryId"); + assertLiteral(source.ownerId, SOURCE_REPOSITORY_OWNER_ID, "manifest.source.ownerId"); + assertString(source.commit, "manifest.source.commit", /^[0-9a-f]{40}$/); + + const release = assertExactKeys(manifest.release, ["tag"], "manifest.release"); + assertString(release.tag, "manifest.release.tag", /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + assertLiteral(source.ref, `refs/tags/${release.tag}`, "manifest.source.ref"); + + const artifact = assertExactKeys( + manifest.artifact, + ["name", "mediaType", "sha256", "size"], + "manifest.artifact" + ); + assertLiteral( + artifact.name, + `opensecret-${release.tag}-${manifest.environment}.eif`, + "manifest.artifact.name" + ); + assertLiteral(artifact.mediaType, "application/vnd.aws.nitro.eif", "manifest.artifact.mediaType"); + assertString(artifact.sha256, "manifest.artifact.sha256", /^[0-9a-f]{64}$/); + assertInteger(artifact.size, "manifest.artifact.size", { positive: true }); + + const measurements = assertExactKeys( + manifest.measurements, + ["algorithm", "requiredPcrs", "pcrs"], + "manifest.measurements" + ); + assertLiteral(measurements.algorithm, "sha384", "manifest.measurements.algorithm"); + if ( + !Array.isArray(measurements.requiredPcrs) || + measurements.requiredPcrs.length !== 3 || + measurements.requiredPcrs.some((value, index) => value !== index) + ) { + fail("manifest.measurements.requiredPcrs must equal [0, 1, 2]"); + } + const pcrs = assertExactKeys(measurements.pcrs, ["0", "1", "2"], "manifest.measurements.pcrs"); + for (const pcr of ["0", "1", "2"]) { + const value = assertString(pcrs[pcr], `manifest.measurements.pcrs.${pcr}`, /^[0-9a-f]{96}$/); + if (/^0+$/.test(value)) { + fail(`manifest.measurements.pcrs.${pcr} must not be all zero`); + } + } + + const build = assertExactKeys( + manifest.build, + ["system", "flakeLockSha256", "derivation", "workflowRun"], + "manifest.build" + ); + assertLiteral(build.system, "nix", "manifest.build.system"); + assertString(build.flakeLockSha256, "manifest.build.flakeLockSha256", /^[0-9a-f]{64}$/); + assertLiteral(build.derivation, `eif-${manifest.environment}`, "manifest.build.derivation"); + const workflowRun = assertString( + build.workflowRun, + "manifest.build.workflowRun", + /^https:\/\/github\.com\/OpenSecretCloud\/opensecret\/actions\/runs\/[1-9]\d*\/attempts\/[1-9]\d*$/ + ); + new URL(workflowRun); + + return manifest; +} + +function parseBundle(rawBytes, path, expectedManifestSha256) { + let bundle; + try { + bundle = JSON.parse(rawBytes.toString("utf8")); + } catch (error) { + fail(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + assertPlainObject(bundle, "bundle"); + assertLiteral(bundle.mediaType, BUNDLE_MEDIA_TYPE, "bundle.mediaType"); + if (!isPlainObject(bundle.messageSignature) || bundle.dsseEnvelope !== undefined) { + fail("bundle must contain a v0.3 messageSignature and no DSSE envelope"); + } + const messageDigest = assertPlainObject( + bundle.messageSignature.messageDigest, + "bundle.messageSignature.messageDigest" + ); + assertLiteral( + messageDigest.algorithm, + "SHA2_256", + "bundle.messageSignature.messageDigest.algorithm" + ); + const encodedDigest = assertString( + messageDigest.digest, + "bundle.messageSignature.messageDigest.digest" + ); + let digest; + try { + digest = Buffer.from(encodedDigest, "base64"); + } catch { + fail("bundle.messageSignature.messageDigest.digest must be base64"); + } + if (digest.length !== 32 || digest.toString("hex") !== expectedManifestSha256) { + fail("bundle message digest does not match the exact manifest bytes"); + } + + const verificationMaterial = assertPlainObject( + bundle.verificationMaterial, + "bundle.verificationMaterial" + ); + if (!isPlainObject(verificationMaterial.certificate)) { + fail("bundle must contain exactly one Fulcio certificate"); + } + if (verificationMaterial.x509CertificateChain !== undefined) { + fail("legacy x509CertificateChain bundles are not accepted"); + } + + const tlogEntries = verificationMaterial.tlogEntries; + if (!Array.isArray(tlogEntries) || tlogEntries.length !== 1) { + fail("bundle must contain exactly one transparency-log entry"); + } + const tlogEntry = assertPlainObject(tlogEntries[0], "bundle.verificationMaterial.tlogEntries[0]"); + const inclusionProof = assertPlainObject( + tlogEntry.inclusionProof, + "bundle.verificationMaterial.tlogEntries[0].inclusionProof" + ); + const checkpoint = assertPlainObject( + inclusionProof.checkpoint, + "bundle.verificationMaterial.tlogEntries[0].inclusionProof.checkpoint" + ); + assertString( + checkpoint.envelope, + "bundle.verificationMaterial.tlogEntries[0].inclusionProof.checkpoint.envelope", + /[\S]/ + ); + + const rawLogIndex = tlogEntry.logIndex; + const encodedLogIndex = + typeof rawLogIndex === "number" && Number.isSafeInteger(rawLogIndex) && rawLogIndex >= 0 + ? String(rawLogIndex) + : assertString(rawLogIndex, "bundle.verificationMaterial.tlogEntries[0].logIndex", /^\d+$/); + const logIndex = BigInt(encodedLogIndex).toString(); + const logId = assertPlainObject( + tlogEntry.logId, + "bundle.verificationMaterial.tlogEntries[0].logId" + ); + const encodedLogIdKey = assertString( + logId.keyId, + "bundle.verificationMaterial.tlogEntries[0].logId.keyId" + ); + const logIdBytes = Buffer.from(encodedLogIdKey, "base64"); + if (logIdBytes.length !== 32) { + fail("bundle.verificationMaterial.tlogEntries[0].logId.keyId must encode 32 bytes"); + } + + return { + bundle, + transparencyLog: { logIndex, logId: logIdBytes.toString("hex") } + }; +} + +function parseArgs(argv) { + const manifests = []; + const bundles = []; + const outputs = []; + let cosign = process.env.COSIGN_BIN || "cosign"; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const value = argv[index + 1]; + if (argument === "--help" || argument === "-h") { + process.stdout.write(usage()); + process.exit(0); + } + if (!value || value.startsWith("--")) { + fail(`missing value for ${argument}`); + } + if (argument === "--manifest") { + manifests.push(resolve(value)); + } else if (argument === "--bundle") { + bundles.push(resolve(value)); + } else if (argument === "--output") { + outputs.push(resolve(value)); + } else if (argument === "--cosign") { + cosign = resolve(value); + } else { + fail(`unknown argument: ${argument}`); + } + index += 1; + } + + if (manifests.length === 0 || manifests.length !== bundles.length) { + fail("supply the same non-zero number of --manifest and --bundle arguments"); + } + + return { manifests, bundles, outputs: outputs.length > 0 ? outputs : defaultOutputs, cosign }; +} + +function parseVersion(text) { + const match = text.match(/v?(\d+)\.(\d+)\.(\d+)/); + if (!match) { + fail(`could not parse Cosign version from: ${text.trim()}`); + } + return match.slice(1).map(Number); +} + +function requireCosign(cosign) { + const result = spawnSync(cosign, ["version", "--json"], { encoding: "utf8" }); + if (result.error) { + fail(`failed to execute Cosign at ${cosign}: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`Cosign version check failed: ${result.stderr || result.stdout}`); + } + const version = parseVersion(result.stdout || result.stderr); + if (version.some((part, index) => part !== REQUIRED_COSIGN_VERSION[index])) { + fail( + `Cosign ${version.join(".")} is not supported; exactly ${REQUIRED_COSIGN_VERSION.join(".")} is required` + ); + } +} + +function expectedSignerIdentity(manifest) { + return `${SOURCE_REPOSITORY_URI}/${WORKFLOW_PATH}@${manifest.source.ref}`; +} + +function verifyWithCosign(cosign, manifestPath, bundlePath, manifest) { + const identity = expectedSignerIdentity(manifest); + const arguments_ = [ + "verify-blob", + "--bundle", + bundlePath, + "--certificate-identity", + identity, + "--certificate-oidc-issuer", + OIDC_ISSUER, + "--certificate-github-workflow-name", + WORKFLOW_NAME, + "--certificate-github-workflow-repository", + SOURCE_REPOSITORY, + "--certificate-github-workflow-ref", + manifest.source.ref, + "--certificate-github-workflow-sha", + manifest.source.commit, + "--certificate-github-workflow-trigger", + WORKFLOW_TRIGGER, + manifestPath + ]; + const result = spawnSync(cosign, arguments_, { encoding: "utf8" }); + if (result.error) { + fail(`failed to execute Cosign: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`Cosign rejected ${manifestPath}: ${result.stderr || result.stdout}`); + } +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function expectedGenericFulcioOids(manifest) { + return { + "1.3.6.1.4.1.57264.1.8": OIDC_ISSUER, + "1.3.6.1.4.1.57264.1.9": expectedSignerIdentity(manifest), + "1.3.6.1.4.1.57264.1.10": manifest.source.commit, + "1.3.6.1.4.1.57264.1.11": "github-hosted", + "1.3.6.1.4.1.57264.1.12": SOURCE_REPOSITORY_URI, + "1.3.6.1.4.1.57264.1.13": manifest.source.commit, + "1.3.6.1.4.1.57264.1.14": manifest.source.ref, + "1.3.6.1.4.1.57264.1.15": String(SOURCE_REPOSITORY_ID), + "1.3.6.1.4.1.57264.1.16": SOURCE_REPOSITORY_OWNER_URI, + "1.3.6.1.4.1.57264.1.17": String(SOURCE_REPOSITORY_OWNER_ID), + "1.3.6.1.4.1.57264.1.18": expectedSignerIdentity(manifest), + "1.3.6.1.4.1.57264.1.19": manifest.source.commit, + "1.3.6.1.4.1.57264.1.20": WORKFLOW_TRIGGER, + "1.3.6.1.4.1.57264.1.21": manifest.build.workflowRun, + "1.3.6.1.4.1.57264.1.22": "public", + "1.3.6.1.4.1.57264.1.23": WORKFLOW_ENVIRONMENT, + "1.3.6.1.4.1.57264.1.24": `repo:${SOURCE_REPOSITORY}:environment:${WORKFLOW_ENVIRONMENT}` + }; +} + +export function decodeDerUtf8String(value, context = "Fulcio extension") { + if (!(value instanceof Uint8Array)) { + fail(`${context} must be a byte string`); + } + + const bytes = Buffer.from(value); + if (bytes.length < 2 || bytes[0] !== 0x0c) { + fail(`${context} must be a DER UTF8String`); + } + + const firstLengthByte = bytes[1]; + let headerLength; + let contentLength; + if (firstLengthByte < 0x80) { + headerLength = 2; + contentLength = firstLengthByte; + } else { + const lengthByteCount = firstLengthByte & 0x7f; + if (lengthByteCount === 0) { + fail(`${context} uses an indefinite DER length`); + } + if (lengthByteCount > 4 || bytes.length < 2 + lengthByteCount) { + fail(`${context} has an invalid DER length`); + } + if (bytes[2] === 0) { + fail(`${context} has a non-minimal DER length`); + } + + contentLength = 0; + for (let index = 0; index < lengthByteCount; index += 1) { + contentLength = contentLength * 256 + bytes[2 + index]; + } + if (contentLength < 0x80) { + fail(`${context} has a non-minimal DER length`); + } + headerLength = 2 + lengthByteCount; + } + + if (headerLength + contentLength !== bytes.length) { + fail(`${context} DER length does not match its value`); + } + + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(headerLength)); + } catch { + fail(`${context} is not valid UTF-8`); + } +} + +function oidToString(oid) { + const components = oid?.id; + if ( + !Array.isArray(components) || + components.length === 0 || + components.some((component) => !Number.isSafeInteger(component) || component < 0) + ) { + return undefined; + } + return components.join("."); +} + +export function verifyGenericFulcioOids(signer, expectedOids) { + const signerOids = signer?.identity?.oids; + if (!Array.isArray(signerOids)) { + fail("verified Fulcio signer did not expose certificate OIDs"); + } + + const expected = new Map(Object.entries(expectedOids)); + const observed = new Map(); + for (const signerOid of signerOids) { + const oid = oidToString(signerOid?.oid); + if (!oid || !expected.has(oid)) { + continue; + } + if (observed.has(oid)) { + fail(`verified Fulcio signer contains duplicate OID ${oid}`); + } + observed.set(oid, decodeDerUtf8String(signerOid.value, `Fulcio OID ${oid}`)); + } + + for (const [oid, expectedValue] of expected) { + if (!observed.has(oid)) { + fail(`verified Fulcio signer is missing OID ${oid}`); + } + const observedValue = observed.get(oid); + if (observedValue !== expectedValue) { + fail( + `verified Fulcio signer has unexpected OID ${oid}: expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(observedValue)}` + ); + } + } +} + +async function verifyWithSigstoreJs(bundle, manifestBytes, manifest) { + const identity = expectedSignerIdentity(manifest); + const signer = await verify(bundle, manifestBytes, { + certificateIssuer: OIDC_ISSUER, + certificateIdentityURI: `^${escapeRegex(identity)}$`, + // sigstore-js compares these deprecated extensions as raw strings. Fulcio's + // provider-generic extensions are DER UTF8Strings, so validate those below. + certificateOIDs: { + "1.3.6.1.4.1.57264.1.2": WORKFLOW_TRIGGER, + "1.3.6.1.4.1.57264.1.3": manifest.source.commit, + "1.3.6.1.4.1.57264.1.4": WORKFLOW_NAME, + "1.3.6.1.4.1.57264.1.5": SOURCE_REPOSITORY, + "1.3.6.1.4.1.57264.1.6": manifest.source.ref + }, + tlogThreshold: 1, + ctLogThreshold: 1 + }); + verifyGenericFulcioOids(signer, expectedGenericFulcioOids(manifest)); +} + +function validateNoDuplicateReleases(releases) { + const unique = { + release: new Set(), + manifest: new Set() + }; + + for (const release of releases) { + const manifest = release.manifest; + const releaseKey = `${manifest.environment}:${manifest.release.tag}`; + for (const [kind, key] of [ + ["release", releaseKey], + ["manifest", release.manifestSha256] + ]) { + if (unique[kind].has(key)) { + fail(`duplicate ${kind} entry in trusted-release inputs: ${key}`); + } + unique[kind].add(key); + } + } +} + +function compareReleaseTags(left, right) { + const leftParts = left.slice(1).split(".").map(BigInt); + const rightParts = right.slice(1).split(".").map(BigInt); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] > rightParts[index]) return 1; + if (leftParts[index] < rightParts[index]) return -1; + } + return 0; +} + +function writeSnapshotAtomically(path, contents) { + const temporaryPath = `${path}.tmp-${process.pid}`; + writeFileSync(temporaryPath, contents, { encoding: "utf8", mode: 0o644 }); + renameSync(temporaryPath, path); +} + +function assertSupportedNodeVersion() { + const [major, minor] = process.versions.node.split(".").map(Number); + if (!((major === 24 && minor >= 15) || major >= 26)) { + fail("trusted-release updates require Node 24.15 or newer supported by sigstore-js 5"); + } +} + +async function main() { + assertSupportedNodeVersion(); + const { manifests, bundles, outputs, cosign } = parseArgs(process.argv.slice(2)); + requireCosign(cosign); + + const releases = []; + for (let index = 0; index < manifests.length; index += 1) { + const manifestPath = manifests[index]; + const bundlePath = bundles[index]; + const manifestBytes = readFileSync(manifestPath); + const bundleBytes = readFileSync(bundlePath); + const manifestSha256 = sha256(manifestBytes); + const manifest = parseCanonicalManifest(manifestBytes, manifestPath); + const { bundle, transparencyLog } = parseBundle(bundleBytes, bundlePath, manifestSha256); + + verifyWithCosign(cosign, manifestPath, bundlePath, manifest); + await verifyWithSigstoreJs(bundle, manifestBytes, manifest); + + releases.push({ + manifestSha256, + bundleSha256: sha256(bundleBytes), + signer: { + oidcIssuer: OIDC_ISSUER, + identity: expectedSignerIdentity(manifest) + }, + transparencyLog, + manifest + }); + } + + releases.sort((left, right) => { + const environmentOrder = + left.manifest.environment < right.manifest.environment + ? -1 + : left.manifest.environment > right.manifest.environment + ? 1 + : 0; + return ( + environmentOrder || compareReleaseTags(left.manifest.release.tag, right.manifest.release.tag) + ); + }); + validateNoDuplicateReleases(releases); + + const snapshotWithoutId = { + schema: SNAPSHOT_SCHEMA, + policy: SNAPSHOT_POLICY, + releases + }; + const snapshot = { + ...snapshotWithoutId, + snapshotId: sha256(Buffer.from(canonicalJson(snapshotWithoutId))) + }; + const output = canonicalJson(snapshot); + + for (const outputPath of outputs) { + writeSnapshotAtomically(outputPath, output); + process.stdout.write(`Wrote ${outputPath}\n`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/sdk/scripts/update-trusted-enclave-releases.test.mjs b/sdk/scripts/update-trusted-enclave-releases.test.mjs new file mode 100644 index 000000000..bd8fdb85b --- /dev/null +++ b/sdk/scripts/update-trusted-enclave-releases.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decodeDerUtf8String, + verifyGenericFulcioOids +} from "./update-trusted-enclave-releases.mjs"; + +function encodeDerUtf8String(value) { + const bytes = Buffer.from(value, "utf8"); + if (bytes.length < 0x80) { + return Buffer.concat([Buffer.from([0x0c, bytes.length]), bytes]); + } + + const lengthBytes = []; + let length = bytes.length; + while (length > 0) { + lengthBytes.unshift(length & 0xff); + length = Math.floor(length / 256); + } + return Buffer.concat([Buffer.from([0x0c, 0x80 | lengthBytes.length, ...lengthBytes]), bytes]); +} + +function signerOid(oid, value) { + return { + oid: { id: oid.split(".").map(Number) }, + value: encodeDerUtf8String(value) + }; +} + +test("decodes canonical short and long DER UTF8String values", () => { + assert.equal(decodeDerUtf8String(encodeDerUtf8String("github-hosted")), "github-hosted"); + const longValue = "a".repeat(256); + assert.equal(decodeDerUtf8String(encodeDerUtf8String(longValue)), longValue); +}); + +test("rejects malformed DER UTF8String values", () => { + for (const malformed of [ + Buffer.from([0x16, 0x01, 0x61]), + Buffer.from([0x0c, 0x80, 0x00, 0x00]), + Buffer.from([0x0c, 0x81, 0x01, 0x61]), + Buffer.from([0x0c, 0x82, 0x00, 0x80, ...Buffer.alloc(0x80)]), + Buffer.from([0x0c, 0x02, 0x61]), + Buffer.from([0x0c, 0x01, 0x61, 0x62]), + Buffer.from([0x0c, 0x01, 0xff]) + ]) { + assert.throws(() => decodeDerUtf8String(malformed)); + } +}); + +test("requires every generic Fulcio claim to match exactly", () => { + const expected = { + "1.3.6.1.4.1.57264.1.9": "workflow identity", + "1.3.6.1.4.1.57264.1.21": "run invocation" + }; + const signer = { + identity: { + oids: Object.entries(expected).map(([oid, value]) => signerOid(oid, value)) + } + }; + + assert.doesNotThrow(() => verifyGenericFulcioOids(signer, expected)); + assert.throws( + () => + verifyGenericFulcioOids( + { + identity: { + oids: [ + signerOid("1.3.6.1.4.1.57264.1.9", "workflow identity"), + signerOid("1.3.6.1.4.1.57264.1.9", "workflow identity"), + signerOid("1.3.6.1.4.1.57264.1.21", "run invocation") + ] + } + }, + expected + ), + /duplicate OID/ + ); + assert.throws( + () => + verifyGenericFulcioOids( + { + identity: { + oids: [signerOid("1.3.6.1.4.1.57264.1.9", "workflow identity")] + } + }, + expected + ), + /missing OID/ + ); + assert.throws( + () => + verifyGenericFulcioOids( + { + identity: { + oids: [ + signerOid("1.3.6.1.4.1.57264.1.9", "wrong identity"), + signerOid("1.3.6.1.4.1.57264.1.21", "run invocation") + ] + } + }, + expected + ), + /unexpected OID/ + ); +}); diff --git a/sdk/src/lib/attestation.ts b/sdk/src/lib/attestation.ts index f6160e940..9f3c58201 100644 --- a/sdk/src/lib/attestation.ts +++ b/sdk/src/lib/attestation.ts @@ -3,6 +3,12 @@ import { decode, encode } from "@stablelib/base64"; import * as cbor from "cbor2"; import { z } from "zod"; import { fetchAttestationDocument, getApiUrl } from "./api"; +import { + assertTrustedReleaseSnapshotIntegrity, + requireTrustedPcrs, + resolveAttestationEnvironment, + type AttestationEnvironment +} from "./pcr"; import awsRootCertDer from "../assets/aws_root.der"; // Assert that the root cert is not empty @@ -246,7 +252,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 { @@ -270,7 +276,8 @@ async function fakeAuthenticate( export async function verifyAttestation( nonce: string, - explicitApiUrl?: string + explicitApiUrl?: string, + expectedEnvironment?: AttestationEnvironment ): Promise { try { const attestationDocumentBase64 = await fetchAttestationDocument(nonce, explicitApiUrl); @@ -288,6 +295,15 @@ 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."); + } + const environment = resolveAttestationEnvironment(apiUrl, expectedEnvironment); + await assertTrustedReleaseSnapshotIntegrity(); + requireTrustedPcrs(verifiedDocument.pcrs, environment); return verifiedDocument; } catch (error) { if (error instanceof Error) { diff --git a/sdk/src/lib/attestationForView.ts b/sdk/src/lib/attestationForView.ts index d30567d83..19ebf5a82 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 { + assertTrustedReleaseSnapshotIntegrity, + validatePcrsAgainstSnapshot, + getTrustedReleaseSnapshotId, + type Pcr0ValidationResult, + type PcrConfig +} from "./pcr"; 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; }; @@ -47,6 +56,7 @@ export async function parseAttestationForView( cabundle: Uint8Array[], pcrConfig?: PcrConfig ): Promise { + await assertTrustedReleaseSnapshotIntegrity(); // Add logging to see what we're getting console.log("Raw timestamp:", document.timestamp); console.log("Date object:", new Date(document.timestamp)); @@ -59,14 +69,13 @@ 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) + : { + 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 +114,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/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..a4ce3f6fa 100644 --- a/sdk/src/lib/getAttestation.ts +++ b/sdk/src/lib/getAttestation.ts @@ -5,10 +5,12 @@ import nacl from "tweetnacl"; import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305"; import { encode, decode } from "@stablelib/base64"; import { - requireTrustedPcr0, + assertTrustedReleaseSnapshotIntegrity, + requireTrustedPcrs, + resolveAttestationEnvironment, serializePcrConfig, snapshotPcrConfig, - validatePcr0Hash, + type AttestationEnvironment, type PcrConfig } from "./pcr"; @@ -39,7 +41,7 @@ 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; + requireTrustedPcrs: typeof requireTrustedPcrs; keyExchange: typeof keyExchange; generateNaclKeyPair: () => NaclKeyPair; decryptSessionKey: ( @@ -79,7 +81,7 @@ function decryptSessionKey( const defaultDependencies: GetAttestationDependencies = { verifyAttestation, - validatePcr0Hash, + requireTrustedPcrs, keyExchange, generateNaclKeyPair, decryptSessionKey, @@ -274,6 +276,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 { @@ -296,7 +301,8 @@ export async function getAttestationWithDependencies( console.log("Generated attestation nonce:", attestationNonce); const document: AttestationDocument = await dependencies.verifyAttestation( attestationNonce, - scope.apiUrl + scope.apiUrl, + expectedEnvironment ); if (!document.public_key) { @@ -308,13 +314,14 @@ export async function getAttestationWithDependencies( verifiedPcr0 = "local-development"; console.warn("LOCAL DEVELOPMENT: PCR0 verification is bypassed for exact HTTP loopback."); } else { - const trustedPcr = await requireTrustedPcr0( - document.pcrs, - policy, - dependencies.validatePcr0Hash - ); - verifiedPcr0 = trustedPcr.hash; - console.log("Attestation PCR0 trust verification succeeded."); + await assertTrustedReleaseSnapshotIntegrity(); + dependencies.requireTrustedPcrs(document.pcrs, expectedEnvironment!); + 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..3c7f5593a 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -143,7 +143,16 @@ 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 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..3b73d8f53 100644 --- a/sdk/src/lib/pcr.ts +++ b/sdk/src/lib/pcr.ts @@ -1,508 +1,500 @@ -/** - * Valid PCR0 values for production environments - */ -const DEFAULT_PCR0_VALUES = [ - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b", - "74ed417f88cb0ca76c4a3d10f278bd010f1d3f95eafb254d4732511bb50e404507a4049b779c5230137e4091a5582271", - "9043fcab93b972d3c14ad2dc8fa78ca7ad374fc937c02435681772a003f7a72876bc4d578089b5c4cf3fe9b480f1aabb", - "52c3595b151d93d8b159c257301bfd5aa6f49210de0c55a6cd6df5ebeee44e4206cab950500f5d188f7fa14e6d900b75", - "91cb67311e910cce68cd5b7d0de77aa40610d87c6681439b44c46c3ff786ae643956ab2c812478a1da8745b259f07a45", - "859065ac81b81d3735130ba08b8af72a7256b603fefb74faabae25ed28cca6edcaa7c10ea32b5948d675c18a9b0f2b1d", - "acd82a7d3943e23e95a9dc3ce0b0107ea358d6287f9e3afa245622f7c7e3e0a66142a928b6efcc02f594a95366d3a99d" -]; +import { z } from "zod"; +import trustedReleaseSnapshotJson from "./trusted-enclave-releases.generated.json"; + +const SNAPSHOT_SCHEMA = "https://opensecret.cloud/sdk/trusted-enclave-releases/v1"; +const MANIFEST_SCHEMA = "https://opensecret.cloud/attestations/nitro-eif-release/v1"; +const SOURCE_REPOSITORY = "OpenSecretCloud/opensecret"; +const EIF_MEDIA_TYPE = "application/vnd.aws.nitro.eif"; +const PCR_HEX_PATTERN = /^[0-9a-f]{96}$/; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; +const COMMIT_HEX_PATTERN = /^[0-9a-f]{40}$/; +const RELEASE_TAG_PATTERN = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const WORKFLOW_PATH = ".github/workflows/release-nitro-eif.yml"; +const OIDC_ISSUER = "https://token.actions.githubusercontent.com"; +const LOCAL_DEVELOPMENT_API_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); + +export const AttestationEnvironmentSchema = z.enum(["prod", "dev"]); +export type AttestationEnvironment = z.infer; + +const PcrMeasurementsSchema = 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((value) => !/^0+$/.test(value)), + "1": z + .string() + .regex(PCR_HEX_PATTERN) + .refine((value) => !/^0+$/.test(value)), + "2": z + .string() + .regex(PCR_HEX_PATTERN) + .refine((value) => !/^0+$/.test(value)) + }) + .strict() + }) + .strict(); + +const ReleaseManifestSchema = z + .object({ + schema: z.literal(MANIFEST_SCHEMA), + environment: AttestationEnvironmentSchema, + source: z + .object({ + repository: z.literal(SOURCE_REPOSITORY), + repositoryId: z.literal(921901924), + ownerId: z.literal(185423582), + ref: z.string().startsWith("refs/tags/"), + commit: z.string().regex(COMMIT_HEX_PATTERN) + }) + .strict(), + release: z + .object({ + tag: z.string().regex(RELEASE_TAG_PATTERN) + }) + .strict(), + artifact: z + .object({ + name: z.string().min(1), + mediaType: z.literal(EIF_MEDIA_TYPE), + sha256: z.string().regex(SHA256_HEX_PATTERN), + size: z.number().safe().int().positive() + }) + .strict(), + measurements: PcrMeasurementsSchema, + build: z + .object({ + system: z.literal("nix"), + flakeLockSha256: z.string().regex(SHA256_HEX_PATTERN), + derivation: z.enum(["eif-prod", "eif-dev"]), + workflowRun: z + .string() + .regex( + /^https:\/\/github\.com\/OpenSecretCloud\/opensecret\/actions\/runs\/[1-9]\d*\/attempts\/[1-9]\d*$/ + ) + }) + .strict() + }) + .strict() + .superRefine((manifest, context) => { + if (manifest.source.ref !== `refs/tags/${manifest.release.tag}`) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["source", "ref"], + message: "source ref must be the exact release tag" + }); + } -/** - * Valid PCR0 values for development environments - */ -const DEFAULT_PCR0_VALUES_DEV = [ - "62c0407056217a4c10764ed9045694c29fa93255d3cc04c2f989cdd9a1f8050c8b169714c71f1118ebce2fcc9951d1a9", - "cb95519905443f9f66f05f63c548b61ad1561a27fd5717b69285861aaea3c3063fe12a2571773b67fea3c6c11b4d8ec6", - "deb5895831b5e4286f5a2dcf5e9c27383821446f8df2b465f141d10743599be20ba3bb381ce063bf7139cc89f7f61d4c", - "70ba26c6af1ec3b57ce80e1adcc0ee96d70224d4c7a078f427895cdf68e1c30f09b5ac4c456588d872f3f21ff77c036b", - "669404ea71435b8f498b48db7816a5c2ab1d258b1a77685b11d84d15a73189504d79c4dee13a658de9f4a0cbfc39cfe8", - "a791bf92c25ffdfd372660e460a0e238c6778c090672df6509ae4bc065cf8668b6baac6b6a11d554af53ee0ff0172ad5", - "c4285443b87b9b12a6cea3bef1064ec060f652b235a297095975af8f134e5ed65f92d70d4616fdec80af9dff48bb9f35" -]; + if ( + manifest.artifact.name !== `opensecret-${manifest.release.tag}-${manifest.environment}.eif` + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["artifact", "name"], + message: "artifact name must match the release tag and environment" + }); + } -/** - * Public key used to verify PCR history signatures in SPKI DER format (base64-encoded) - */ -const PCR_VERIFICATION_PUBLIC_KEY_B64 = - "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEHiUY9kFWK1GqBGzczohhwEwElXzgWLDZa9R6wBx3JOBocgSt9+UIzZlJbPDjYeGBfDUXh7Z62BG2vVsh2NgclLB5S7A2ucBBtb1wd8vSQHP8jpdPhZX1slauPgbnROIP"; + if (manifest.build.derivation !== `eif-${manifest.environment}`) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["build", "derivation"], + message: "build derivation must match the release environment" + }); + } + }); + +const TrustedReleaseSchema = z + .object({ + manifestSha256: z.string().regex(SHA256_HEX_PATTERN), + bundleSha256: z.string().regex(SHA256_HEX_PATTERN), + signer: z + .object({ + oidcIssuer: z.literal(OIDC_ISSUER), + identity: z.string().url() + }) + .strict(), + transparencyLog: z + .object({ + logIndex: z.string().regex(/^(0|[1-9]\d*)$/), + logId: z.string().regex(SHA256_HEX_PATTERN) + }) + .strict(), + manifest: ReleaseManifestSchema + }) + .strict() + .superRefine((release, context) => { + const expectedIdentity = `https://github.com/${SOURCE_REPOSITORY}/${WORKFLOW_PATH}@${release.manifest.source.ref}`; + if (release.signer.identity !== expectedIdentity) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["signer", "identity"], + message: "signer identity must match the exact release workflow and manifest tag" + }); + } + }); + +const TrustedReleaseSnapshotSchema = z + .object({ + schema: z.literal(SNAPSHOT_SCHEMA), + snapshotId: z.string().regex(SHA256_HEX_PATTERN), + policy: z + .object({ + oidcIssuer: z.literal(OIDC_ISSUER), + sourceRepository: z.literal(SOURCE_REPOSITORY), + sourceRepositoryId: z.literal(921901924), + sourceRepositoryOwnerId: z.literal(185423582), + workflow: z + .object({ + path: z.literal(WORKFLOW_PATH), + name: z.literal("Nitro EIF Release"), + trigger: z.literal("workflow_dispatch"), + environment: z.literal("production-release") + }) + .strict() + }) + .strict(), + releases: z.array(TrustedReleaseSchema) + }) + .strict() + .superRefine((snapshot, context) => { + const releaseKeys = new Set(); + const manifestDigests = new Set(); + snapshot.releases.forEach((release, index) => { + const releaseKey = `${release.manifest.environment}:${release.manifest.release.tag}`; + if (releaseKeys.has(releaseKey)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["releases", index], + message: "duplicate release environment and tag" + }); + } + releaseKeys.add(releaseKey); + + if (manifestDigests.has(release.manifestSha256)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["releases", index, "manifestSha256"], + message: "duplicate release manifest digest" + }); + } + manifestDigests.add(release.manifestSha256); + }); + }); -/** - * Remote PCR history URLs - */ -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 TrustedEnclaveRelease = z.infer; +export type TrustedEnclaveReleaseSnapshot = z.infer; -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; +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; } -/** - * PCR history entry type - */ -export type PcrHistoryEntry = { - PCR0: string; - PCR1: string; - PCR2: string; - timestamp: number; - signature: string; -}; - -/** - * 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) */ - verifiedAt?: string; -}; - -/** The OpenSecret deployment environment whose PCR0 roots are trusted. */ -export type PcrEnvironment = "production" | "development"; +const TRUSTED_RELEASE_SNAPSHOT = deepFreeze( + TrustedReleaseSnapshotSchema.parse(trustedReleaseSnapshotJson) +); +let snapshotIntegrityPromise: Promise | undefined; -/** - * 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; - }; -}; - -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; +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson); } - - 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(); + if (value !== null && typeof value === "object") { + const object = value as Record; + return Object.fromEntries( + Object.keys(object) + .sort() + .map((key) => [key, sortJson(object[key])]) + ); } + return value; } -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(); +async function sha256CanonicalJson(value: unknown): Promise { + const canonicalBytes = new TextEncoder().encode(`${JSON.stringify(sortJson(value), null, 2)}\n`); + const digest = await crypto.subtle.digest("SHA-256", canonicalBytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } -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 assertTrustedReleaseSnapshotIntegrity(): Promise { + snapshotIntegrityPromise ??= (async () => { + const snapshotPayload = { + schema: TRUSTED_RELEASE_SNAPSHOT.schema, + policy: TRUSTED_RELEASE_SNAPSHOT.policy, + releases: TRUSTED_RELEASE_SNAPSHOT.releases + }; + const actualSnapshotId = await sha256CanonicalJson(snapshotPayload); + if (actualSnapshotId !== TRUSTED_RELEASE_SNAPSHOT.snapshotId) { + throw new Error("Embedded trusted-release snapshot ID is invalid."); + } + + for (const release of TRUSTED_RELEASE_SNAPSHOT.releases) { + const actualManifestSha256 = await sha256CanonicalJson(release.manifest); + if (actualManifestSha256 !== release.manifestSha256) { + throw new Error( + `Embedded trusted-release manifest digest is invalid for ${release.manifest.release.tag}.` + ); + } + } + })(); + return snapshotIntegrityPromise; } /** - * Takes an immutable snapshot of a PCR policy before an asynchronous handshake. - * This prevents caller mutations from changing which policy is validated or cached. + * Attestation policy configuration. + * + * Non-loopback deployments whose origin is not one of the SDK's exact official + * origins must select an environment explicitly. Raw PCR allowlists and remote + * PCR-history URLs are intentionally no longer supported. */ +export type PcrConfig = { + environment?: AttestationEnvironment; + /** @deprecated Raw PCR overrides are no longer an authorization mechanism. */ + pcr0Values?: never; + /** @deprecated Raw PCR overrides are no longer an authorization mechanism. */ + pcr0DevValues?: never; + /** @deprecated Runtime PCR-history fetching has been removed. */ + remoteAttestation?: never; + /** @deprecated Runtime PCR-history fetching has been removed. */ + remoteAttestationUrls?: never; +}; + +/** Return a detached, immutable copy suitable for an attestation session policy. */ 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 - } - }; + return Object.freeze({ environment: config?.environment }); } -/** Stable representation used to bind cached sessions to their trust policy. */ +/** Canonical policy fingerprint input used to scope cached attestation sessions. */ 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({ + version: "sigstore-trusted-release-v1", + environment: snapshot.environment ?? null, + snapshotId: TRUSTED_RELEASE_SNAPSHOT.snapshotId + }); +} + +export type Pcr0ValidationResult = { + /** Whether PCR0, PCR1, and PCR2 match one authenticated release as a tuple. */ + isMatch: boolean; + /** Human-readable description of the validation result. */ + text: string; + /** Environment selected by caller policy. */ + environment?: AttestationEnvironment; + releaseTag?: string; + sourceCommit?: string; + sourceRef?: string; + artifactSha256?: string; + manifestSha256?: string; + bundleSha256?: string; + snapshotId: string; + signerIdentity?: string; + transparencyLog?: { + logIndex: string; + logId: string; }; + /** + * Retained for source compatibility. Sigstore v0.3 verification does not + * expose Rekor integratedTime as a trusted timestamp. + */ + verifiedAt?: string; +}; - return JSON.stringify(canonical); -} +export type PcrValidationResult = Pcr0ValidationResult; -/** 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." - ); +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"] +]); + +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."); } - if (pcr0.every((byte) => byte === 0)) { - throw new Pcr0ValidationError( - "PCR0_ALL_ZERO", - "Attestation document contains an all-zero PCR0." - ); + 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; +} - 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." - ); - } - return hash; +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}`; } -/** - * 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." - ); +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."); } - return { hash, validation }; -} + const origin = normalizeApiOrigin(apiUrl); + const officialEnvironment = OFFICIAL_ENVIRONMENTS_BY_ORIGIN.get(origin); -/** - * 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)) + if (officialEnvironment && explicitEnvironment && explicitEnvironment !== officialEnvironment) { + throw new Error( + `Attestation environment ${explicitEnvironment} is not allowed for official origin ${origin}.` ); + } - // 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 + const environment = officialEnvironment ?? explicitEnvironment; + if (!environment) { + throw new Error( + `Attestation environment must be configured explicitly for non-official origin ${origin}.` ); - } catch (error) { - console.error("Error importing verification key:", error); - throw new Error("Failed to import PCR verification key"); } -} -/** - * 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"); - } + return environment; +} - const response = await fetch(baseUrl, { - redirect: "error", - signal: controller.signal - }); +export function getTrustedReleaseSnapshotId(): string { + return TRUSTED_RELEASE_SNAPSHOT.snapshotId; +} - if (!response.ok || response.redirected) { - throw new Error(`Failed to fetch PCR history: ${response.status}`); - } +export function getTrustedReleaseSnapshot(): TrustedEnclaveReleaseSnapshot { + return TRUSTED_RELEASE_SNAPSHOT; +} - const contentLength = response.headers.get("content-length"); - if (contentLength && Number(contentLength) > PCR_HISTORY_MAX_BYTES) { - throw new Error("PCR history response is too large"); - } +function pcrBytesToHex(value: Uint8Array | undefined): string | null { + if (!value || value.length !== 48) { + return null; + } - const text = await readBoundedUtf8Body(response, PCR_HISTORY_MAX_BYTES); + 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): PcrValidationResult { + const { manifest } = release; + return { + isMatch: true, + text: `PCR0/PCR1/PCR2 match Sigstore-verified ${manifest.environment} release ${manifest.release.tag}`, + environment: manifest.environment, + releaseTag: manifest.release.tag, + sourceCommit: manifest.source.commit, + sourceRef: manifest.source.ref, + artifactSha256: manifest.artifact.sha256, + manifestSha256: release.manifestSha256, + bundleSha256: release.bundleSha256, + snapshotId: TRUSTED_RELEASE_SNAPSHOT.snapshotId, + signerIdentity: release.signer.identity, + transparencyLog: release.transparencyLog + }; } -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 = TRUSTED_RELEASE_SNAPSHOT +): PcrValidationResult { + const actualPcrs = { + "0": pcrBytesToHex(pcrs.get(0)), + "1": pcrBytesToHex(pcrs.get(1)), + "2": pcrBytesToHex(pcrs.get(2)) + }; - try { - return atob(candidate.signature).length === PCR_SIGNATURE_BYTES; - } catch { - return false; + if (!actualPcrs["0"] || !actualPcrs["1"] || !actualPcrs["2"]) { + return { + isMatch: false, + text: "Attestation document must contain 48-byte PCR0, PCR1, and PCR2 values", + environment, + snapshotId: snapshot.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 matches = snapshot.releases.filter((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 (matches.length === 0) { + return { + isMatch: false, + text: `PCR0/PCR1/PCR2 do not match a trusted ${environment} release`, + environment, + snapshotId: snapshot.snapshotId + }; } + + matches.sort((left, right) => + compareReleaseTags(right.manifest.release.tag, left.manifest.release.tag) + ); + return { + ...matchedReleaseResult(matches[0]), + snapshotId: snapshot.snapshotId + }; } -/** - * 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); - - // 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() - }; - } - } - } +function compareReleaseTags(left: string, right: string): number { + const leftParts = left.slice(1).split(".").map(BigInt); + const rightParts = right.slice(1).split(".").map(BigInt); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] > rightParts[index]) return 1; + if (leftParts[index] < rightParts[index]) return -1; + } + return 0; +} - // 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; +export function requireTrustedPcrs( + pcrs: ReadonlyMap, + environment: AttestationEnvironment +): PcrValidationResult { + const result = validatePcrsAgainstSnapshot(pcrs, environment); + 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 + * Display-only compatibility helper. PCR0 by itself is never used to authorize + * key exchange; runtime authorization calls requireTrustedPcrs with PCR0/1/2. */ 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)) { - return { - isMatch: true, - text: development ? "PCR0 matches development enclave" : "PCR0 matches a known good value" - }; - } - - // 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 environment = config?.environment; + const matches = TRUSTED_RELEASE_SNAPSHOT.releases.filter( + (release) => + (!environment || release.manifest.environment === environment) && + release.manifest.measurements.pcrs["0"] === hash + ); + + if (matches.length > 0) { + matches.sort((left, right) => + compareReleaseTags(right.manifest.release.tag, left.manifest.release.tag) + ); + return matchedReleaseResult(matches[0]); } return { isMatch: false, - text: "PCR0 does not match a known good value" + text: "PCR0 does not match a trusted release; full PCR0/PCR1/PCR2 verification is required", + environment, + snapshotId: TRUSTED_RELEASE_SNAPSHOT.snapshotId }; } 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/getAttestationSecurity.test.ts b/sdk/src/lib/test/getAttestationSecurity.test.ts index 0d8ddaaa1..f28dac48c 100644 --- a/sdk/src/lib/test/getAttestationSecurity.test.ts +++ b/sdk/src/lib/test/getAttestationSecurity.test.ts @@ -14,8 +14,7 @@ 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" }; function bytesToHex(bytes: Uint8Array): string { @@ -24,7 +23,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 +47,11 @@ function dependencies( ): GetAttestationDependencies { return { verifyAttestation: async () => attestationDocument(TRUSTED_PCR0), - validatePcr0Hash: async () => ({ + requireTrustedPcrs: () => ({ 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 +85,23 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const validatePcr0Hash = mock(async () => ({ + const requireTrustedPcrs = mock(() => ({ 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, + requireTrustedPcrs, keyExchange }) ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrs).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -105,22 +111,23 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const validatePcr0Hash = mock(async () => ({ + const requireTrustedPcrs = mock(() => ({ 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, + requireTrustedPcrs, keyExchange }) ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrs).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -130,22 +137,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 requireTrustedPcrs = mock(() => { + throw new Error("PCR tuple is not trusted"); + }); await expect( establish( dependencies({ verifyAttestation: async () => attestationDocument(new Uint8Array(48)), - validatePcr0Hash, + requireTrustedPcrs, keyExchange }) ) - ).rejects.toThrow(/PCR0/i); + ).rejects.toThrow(/PCR/i); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrs).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -155,34 +161,32 @@ 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 requireTrustedPcrs = mock(() => { + 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({ requireTrustedPcrs, keyExchange }))).rejects.toThrow( + /PCR/i ); - expect(validatePcr0Hash).toHaveBeenCalledWith( - bytesToHex(TRUSTED_PCR0), - expect.objectContaining({ ...PCR_CONFIG, environment: "production" }) - ); + expect(requireTrustedPcrs).toHaveBeenCalledWith(expect.any(Map), "prod"); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); 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 requireTrustedPcrs = mock(() => ({ 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 deps = dependencies({ verifyAttestation, requireTrustedPcrs, keyExchange }); const established = await establish(deps); const cached = await establish(deps); @@ -190,11 +194,8 @@ 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(requireTrustedPcrs).toHaveBeenCalledTimes(1); + expect(requireTrustedPcrs).toHaveBeenCalledWith(expect.any(Map), "prod"); expect(keyExchange).toHaveBeenCalledTimes(1); expect( window.sessionStorage.getItem( @@ -205,11 +206,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 +221,15 @@ describe("attested session establishment", () => { establish( dependencies({ verifyAttestation, - validatePcr0Hash: async () => ({ isMatch: false, text: "not in changed policy" }), + requireTrustedPcrs: () => { + 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 +239,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,32 +253,27 @@ 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 requireTrustedPcrs = mock(() => { + 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, requireTrustedPcrs, 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(requireTrustedPcrs).toHaveBeenCalledWith(expect.any(Map), "dev"); expect(keyExchange).not.toHaveBeenCalled(); }); @@ -352,7 +347,7 @@ describe("attested session establishment", () => { }); test("bypasses PCR validation only for an exact HTTP loopback API URL", async () => { - const validatePcr0Hash = mock(async () => { + const requireTrustedPcrs = mock(() => { throw new Error("loopback must not invoke PCR validation"); }); const keyExchange = mock(async () => ({ @@ -363,14 +358,14 @@ describe("attested session establishment", () => { const result = await establish( dependencies({ verifyAttestation: async () => attestationDocument(), - validatePcr0Hash, + requireTrustedPcrs, keyExchange }), LOCAL_API_URL ); expect(result).toEqual({ sessionKey: SESSION_KEY, sessionId: "local-session" }); - expect(validatePcr0Hash).not.toHaveBeenCalled(); + expect(requireTrustedPcrs).not.toHaveBeenCalled(); expect(keyExchange).toHaveBeenCalledTimes(1); }); @@ -390,7 +385,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..05287d73b 100644 --- a/sdk/src/lib/test/integration/attestation.test.ts +++ b/sdk/src/lib/test/integration/attestation.test.ts @@ -34,12 +34,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 +46,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" ]; 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..290da08b2 --- /dev/null +++ b/sdk/src/lib/test/integration/attestationSession.test.ts @@ -0,0 +1,87 @@ +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 expired or cross-policy cached sessions", 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("fresh attestation required"); + 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..3cf0cfb0d 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 embedded trusted-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..190e11c4a 100644 --- a/sdk/src/lib/test/integration/pcr.test.ts +++ b/sdk/src/lib/test/integration/pcr.test.ts @@ -1,517 +1,186 @@ -import { expect, test, mock, afterAll } from "bun:test"; -import { serializePcrConfig, snapshotPcrConfig, validatePcr0Hash, type PcrConfig } from "../../pcr"; +import { expect, test } from "bun:test"; +import trustedReleaseSnapshotJson from "../../trusted-enclave-releases.generated.json"; +import { + assertTrustedReleaseSnapshotIntegrity, + getTrustedReleaseSnapshot, + normalizeApiBaseUrl, + normalizeApiOrigin, + resolveAttestationEnvironment, + validatePcr0Hash, + validatePcrsAgainstSnapshot, + type AttestationEnvironment, + type TrustedEnclaveRelease, + type TrustedEnclaveReleaseSnapshot +} from "../../pcr"; + +const PCR0 = "01".repeat(48); +const PCR1 = "02".repeat(48); +const PCR2 = "03".repeat(48); + +function hexToBytes(value: string): Uint8Array { + return new Uint8Array(value.match(/../g)!.map((byte) => Number.parseInt(byte, 16))); +} + +function pcrMap(values = { "0": PCR0, "1": PCR1, "2": PCR2 }) { + return new Map([ + [0, hexToBytes(values["0"])], + [1, hexToBytes(values["1"])], + [2, hexToBytes(values["2"])] + ]); +} -// Mock localStorage and other browser APIs -const storageMock = () => { - const storage: Record = {}; +function release(tag: string, environment: AttestationEnvironment = "prod"): TrustedEnclaveRelease { + const sourceRef = `refs/tags/${tag}`; return { - getItem: (key: string) => storage[key] ?? null, - setItem: (key: string, value: string) => { - storage[key] = value; - }, - removeItem: (key: string) => { - delete storage[key]; + manifestSha256: "10".repeat(32), + bundleSha256: "11".repeat(32), + signer: { + oidcIssuer: "https://token.actions.githubusercontent.com", + identity: `https://github.com/OpenSecretCloud/opensecret/.github/workflows/release-nitro-eif.yml@${sourceRef}` }, - clear: () => { - Object.keys(storage).forEach((key) => delete storage[key]); + transparencyLog: { + logIndex: "1234", + logId: "12".repeat(32) }, - 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 { - 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; + manifest: { + schema: "https://opensecret.cloud/attestations/nitro-eif-release/v1", + environment, + source: { + repository: "OpenSecretCloud/opensecret", + repositoryId: 921901924, + ownerId: 185423582, + ref: sourceRef, + commit: "13".repeat(20) + }, + release: { tag }, + artifact: { + name: `opensecret-${tag}-${environment}.eif`, + mediaType: "application/vnd.aws.nitro.eif", + sha256: "14".repeat(32), + size: 123 + }, + measurements: { + algorithm: "sha384", + requiredPcrs: [0, 1, 2], + pcrs: { "0": PCR0, "1": PCR1, "2": PCR2 } + }, + build: { + system: "nix", + flakeLockSha256: "15".repeat(32), + derivation: `eif-${environment}`, + workflowRun: "https://github.com/OpenSecretCloud/opensecret/actions/runs/1234/attempts/1" + } } - } 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; +function snapshot(releases: TrustedEnclaveRelease[]): TrustedEnclaveReleaseSnapshot { + return { + ...getTrustedReleaseSnapshot(), + snapshotId: "16".repeat(32), + releases + }; +} - if (url.includes("pcr")) { - return createMockResponse([ - { - PCR0: VERIFIED_PCR0, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (value !== null && typeof value === "object") { + const object = value as Record; + return Object.fromEntries( + Object.keys(object) + .sort() + .map((key) => [key, sortJson(object[key])]) + ); } - return createMockResponse(null, 404, false); -}); - -// 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("validates known development PCR0 values", async () => { - const result = await validatePcr0Hash(VALID_PCR0_DEV, { - environment: "development", - remoteAttestation: 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); -}); + return value; +} -test("development rejects production PCR0 values", async () => { - const result = await validatePcr0Hash(VALID_PCR0_PROD, { - environment: "development", - remoteAttestation: false +test("generated trusted-release snapshot ID covers the canonical policy and releases", async () => { + const { snapshotId, ...snapshotPayload } = trustedReleaseSnapshotJson; + const canonical = `${JSON.stringify(sortJson(snapshotPayload), null, 2)}\n`; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); + const actual = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0") + ).join(""); + + expect(actual).toBe(snapshotId); + await expect(assertTrustedReleaseSnapshotIntegrity()).resolves.toBeUndefined(); +}); + +test("authorizes only a complete PCR0/PCR1/PCR2 tuple in the selected environment", () => { + const trusted = snapshot([release("v1.0.0")]); + const valid = validatePcrsAgainstSnapshot(pcrMap(), "prod", trusted); + expect(valid.isMatch).toBe(true); + expect(valid.releaseTag).toBe("v1.0.0"); + expect(valid.environment).toBe("prod"); + expect(valid.transparencyLog).toEqual({ + logIndex: "1234", + logId: "12".repeat(32) }); - 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("additional PCR0 values are scoped to the selected environment", async () => { - const config: PcrConfig = { - pcr0Values: [CUSTOM_PCR0], - pcr0DevValues: [CUSTOM_PCR0_DEV], - remoteAttestation: false - }; - - 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("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 + const changedPcr1 = validatePcrsAgainstSnapshot( + pcrMap({ "0": PCR0, "1": "04".repeat(48), "2": PCR2 }), + "prod", + trusted ); + expect(changedPcr1.isMatch).toBe(false); + expect(validatePcrsAgainstSnapshot(pcrMap(), "dev", trusted).isMatch).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("requires all three 48-byte PCR values", () => { + const trusted = snapshot([release("v1.0.0")]); + const missing = pcrMap(); + missing.delete(2); + expect(validatePcrsAgainstSnapshot(missing, "prod", trusted).isMatch).toBe(false); -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("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 - } - ]); - } - ); - - 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 short = pcrMap(); + short.set(1, new Uint8Array(47)); + expect(validatePcrsAgainstSnapshot(short, "prod", trusted).isMatch).toBe(false); }); -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 - } - ]); - } - ); - - 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("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 result = await validatePcr0Hash(VERIFIED_PCR0, config); - - // Should match the local value, not the remote one +test("identical reproducible tuples across tags select the highest semantic version", () => { + const trusted = snapshot([release("v1.0.9"), release("v1.10.0"), release("v1.2.0")]); + const result = validatePcrsAgainstSnapshot(pcrMap(), "prod", trusted); 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(); + expect(result.releaseTag).toBe("v1.10.0"); }); -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"); - } - ); - - 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; - } -}); - -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 - } - ]); - } - return createMockResponse(null, 404, false); - } - ); - - try { - global.fetch = customFetch; - - const config: PcrConfig = { - remoteAttestationUrls: { - prod: "https://custom.example.com/prod.json", - dev: "https://custom.example.com/dev.json" - } - }; - - 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; - } +test("PCR0 compatibility helper cannot authorize the empty production snapshot", async () => { + const result = await validatePcr0Hash(PCR0, { environment: "prod" }); + expect(result.isMatch).toBe(false); + expect(result.text).toContain("full PCR0/PCR1/PCR2 verification is required"); }); -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 - } - ]); - } - ); - - 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" - } - }); - expect(result.isMatch).toBe(true); - expect(customFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } +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("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 - } - ]); - } +test("accepts HTTPS and exact HTTP loopback URLs only", () => { + expect(normalizeApiOrigin("https://api.opensecret.cloud/v1")).toBe( + "https://api.opensecret.cloud" ); - - 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; - } -}); - -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 - } - ]); - } - return createMockResponse([ - { - PCR0: CUSTOM_PCR0_DEV, - PCR1: VERIFIED_PCR1, - PCR2: VERIFIED_PCR2, - timestamp: 1743710235, - signature: VERIFIED_SIGNATURE - } - ]); - } - ); - - try { - global.fetch = historyFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0, { - environment: "development" - }); - expect(result.isMatch).toBe(false); - expect(historyFetch).toHaveBeenCalledTimes(1); - expect(String(historyFetch.mock.calls[0]?.[0])).toContain("pcrDevHistory.json"); - } finally { - global.fetch = originalFetch; - } -}); - -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` - } - ]); - }); - - 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 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])); - }, - cancel() { - cancelCount += 1; - } - }); - return new Response(stream, { status: 200 }); - }); - - 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("rejects signed PCR history with a malformed schema", async () => { - const malformedHistoryFetch = mock(async (): Promise => { - return createMockResponse([{ PCR0: VERIFIED_PCR0 }]); - }); - - try { - global.fetch = malformedHistoryFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(malformedHistoryFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; - } -}); - -test("rejects redirected signed PCR history responses", async () => { - const redirectedHistoryFetch = mock(async (): Promise => { - return createMockResponse([], 200, true, true); - }); - - try { - global.fetch = redirectedHistoryFetch; - const result = await validatePcr0Hash(VERIFIED_PCR0); - expect(result.isMatch).toBe(false); - expect(redirectedHistoryFetch).toHaveBeenCalledTimes(1); - } finally { - global.fetch = originalFetch; + 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/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/trusted-enclave-releases.generated.json b/sdk/src/lib/trusted-enclave-releases.generated.json new file mode 100644 index 000000000..4383ab52d --- /dev/null +++ b/sdk/src/lib/trusted-enclave-releases.generated.json @@ -0,0 +1,17 @@ +{ + "policy": { + "oidcIssuer": "https://token.actions.githubusercontent.com", + "sourceRepository": "OpenSecretCloud/opensecret", + "sourceRepositoryId": 921901924, + "sourceRepositoryOwnerId": 185423582, + "workflow": { + "environment": "production-release", + "name": "Nitro EIF Release", + "path": ".github/workflows/release-nitro-eif.yml", + "trigger": "workflow_dispatch" + } + }, + "releases": [], + "schema": "https://opensecret.cloud/sdk/trusted-enclave-releases/v1", + "snapshotId": "f5caf5bcb6abcdae2bac8cde92ce2d3722afc65c9e7bd39c9c5a1f2ad7780052" +} From cf2931a7294f3d534108ae6ec6117416a9dd85ce Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:23:05 +0000 Subject: [PATCH 4/7] Fix Sigstore monorepo integration safeguards --- frontend/src/app.tsx | 19 +++++---- sdk/rust/.env.example | 4 +- sdk/rust/src/client.rs | 92 +++++++++++++++++++++++++++++++----------- 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 07a843452..d883604e2 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -12,6 +12,7 @@ import { NotFoundFallback } from "./components/NotFoundFallback"; import { BillingServiceProvider } from "./components/BillingServiceProvider"; import { DeepLinkHandler } from "./components/DeepLinkHandler"; import { NotificationProvider } from "./contexts/NotificationContext"; +import { ChatTypographyProvider } from "./contexts/ChatTypographyContext"; import { ThemeProvider } from "./contexts/ThemeContext"; import { ProxyEventListener } from "./components/ProxyEventListener"; import { UpdateEventListener } from "./components/UpdateEventListener"; @@ -68,14 +69,16 @@ export default function App() { - - - - - - - - + + + + + + + + + + diff --git a/sdk/rust/.env.example b/sdk/rust/.env.example index 1f6189e34..81172024f 100644 --- a/sdk/rust/.env.example +++ b/sdk/rust/.env.example @@ -1,7 +1,7 @@ # OpenSecret API Configuration VITE_OPEN_SECRET_API_URL=http://localhost:3000 -# PCR trust environment: production (default) or development -VITE_OPEN_SECRET_PCR_ENVIRONMENT=production +# Trusted-release environment: prod (default) or dev +VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=prod # Test credentials VITE_TEST_EMAIL=your-email@example.com diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index f36e8ac6a..948026bcc 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -424,7 +424,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,6 +469,19 @@ 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}")))?; @@ -485,9 +498,10 @@ fn default_attestation_environment(base_url: &str) -> Result ResponseTemplate { let nonce = request.url.path().rsplit('/').next().unwrap_or_default(); @@ -2862,12 +2874,14 @@ mod tests { } } + #[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(); @@ -2888,12 +2902,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(); @@ -2915,12 +2931,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(); @@ -2952,6 +2970,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![ ( @@ -3136,6 +3155,19 @@ mod tests { async fn test_client_creation() { let client = OpenSecretClient::new("http://localhost:3000").unwrap(); assert_eq!(client.base_url, "http://localhost:3000"); + } + + #[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); } @@ -3180,18 +3212,22 @@ mod tests { assert!(!client.use_mock_attestation, "unexpected mock URL: {url}"); } + 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 = TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); assert!( - OpenSecretClient::new("http://localhost:3000") - .unwrap() - .use_mock_attestation - ); - assert!( - OpenSecretClient::new("http://127.0.0.1:3000") - .unwrap() - .use_mock_attestation - ); - assert!( - OpenSecretClient::new("http://[::1]:3000") + !OpenSecretClient::new_with_attestation_policy("https://localhost:3000", policy) .unwrap() .use_mock_attestation ); @@ -3217,7 +3253,10 @@ 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 = @@ -3242,6 +3281,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; @@ -3784,6 +3824,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; @@ -3864,6 +3905,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; @@ -3977,6 +4019,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; @@ -4716,6 +4759,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; @@ -5048,6 +5092,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; @@ -5998,6 +6043,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; From 234aed5191e04f8dfdefffb4c94d7f11ebcd2095 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:30:10 +0000 Subject: [PATCH 5/7] Preserve explicit local mock attestation workflow --- README.md | 8 ++++++-- frontend/src-tauri/Cargo.toml | 9 +++++++++ frontend/src-tauri/src/maple_api.rs | 17 +++++++++++++++++ justfile | 4 ++-- scripts/ci/rust.sh | 1 + scripts/ci/verify-local-rust-deps.sh | 26 +++++++++++++++++++++++++- 6 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4087d2a72..b29d58320 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ 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 @@ -145,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: diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index cd7aa7c5e..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" diff --git a/frontend/src-tauri/src/maple_api.rs b/frontend/src-tauri/src/maple_api.rs index 5ce62e3bb..a5ba54ee5 100644 --- a/frontend/src-tauri/src/maple_api.rs +++ b/frontend/src-tauri/src/maple_api.rs @@ -680,6 +680,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}, @@ -687,9 +688,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; @@ -743,6 +748,7 @@ mod tests { release: Arc, } + #[cfg(feature = "insecure-local-mock-attestation")] #[derive(Clone)] struct RefreshThenStallState { key_pair: Arc, @@ -751,6 +757,7 @@ mod tests { retry_started: Arc, } + #[cfg(feature = "insecure-local-mock-attestation")] struct RefreshThenStallFixture { session: Arc, sink: Arc, @@ -758,6 +765,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![ ( @@ -782,6 +790,7 @@ mod tests { BASE64.encode(cose_bytes) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn attestation_handler( State(state): State, Path(nonce): Path, @@ -794,6 +803,7 @@ mod tests { })) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn key_exchange_handler( State(state): State, Json(request): Json, @@ -813,6 +823,7 @@ mod tests { })) } + #[cfg(feature = "insecure-local-mock-attestation")] async fn refresh_handler( State(state): State, ) -> Json { @@ -825,6 +836,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, @@ -844,6 +856,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()); @@ -892,6 +905,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 { @@ -1079,6 +1093,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; @@ -1118,6 +1133,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; @@ -1143,6 +1159,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/justfile b/justfile index 7d1069844..281d389f5 100644 --- a/justfile +++ b/justfile @@ -66,7 +66,7 @@ desktop-build: _verify-rust-lock # Run Tauri desktop development build without the Rust file watcher, using workspace-local config when available desktop-dev: _verify-rust-lock - cd frontend && if [ -f ../.local/tauri-workspace.json ]; then src-tauri/scripts/run-with-desktop-onnxruntime.sh bun tauri dev --no-watch --config ../.local/tauri-workspace.json; else src-tauri/scripts/run-with-desktop-onnxruntime.sh bun tauri dev --no-watch; fi + cd frontend && if [ -f ../.local/tauri-workspace.json ]; then src-tauri/scripts/run-with-desktop-onnxruntime.sh bun tauri dev --no-watch --features insecure-local-mock-attestation --config ../.local/tauri-workspace.json; else src-tauri/scripts/run-with-desktop-onnxruntime.sh bun tauri dev --no-watch --features insecure-local-mock-attestation; fi # Build Tauri desktop debug desktop-build-debug: _verify-rust-lock @@ -78,7 +78,7 @@ desktop-build-debug-overlay: _verify-rust-lock set -euo pipefail test -f .local/tauri-workspace.json || { echo "missing .local/tauri-workspace.json" >&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/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/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." From e3697be72134c77295de1674c73f7ef3d1a836f0 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:08:45 +0000 Subject: [PATCH 6/7] Load enclave trust policy dynamically from TUF --- .github/workflows/sdk-rust.yml | 1 + .github/workflows/sdk-typescript.yml | 1 - README.md | 30 +- frontend/src-tauri/Cargo.lock | 366 +- frontend/src-tauri/src/agent/provider.rs | 68 +- frontend/src-tauri/src/maple_api.rs | 5 +- frontend/src/routes/proof.tsx | 26 +- proxy/.env.example | 2 +- proxy/Cargo.lock | 686 +- proxy/README.md | 67 +- proxy/flake.nix | 3 +- proxy/src/config.rs | 12 +- proxy/src/proxy.rs | 150 +- scripts/ci/change_detection.py | 15 + scripts/ci/test_change_detection.py | 12 + sdk/README.md | 124 +- sdk/bun.lock | 111 - sdk/docs/PLATFORM.md | 2 +- sdk/flake.lock | 19 +- sdk/flake.nix | 33 +- sdk/package.json | 5 +- sdk/rust/.env.example | 2 - sdk/rust/Cargo.lock | 757 +- sdk/rust/Cargo.toml | 9 +- sdk/rust/README.md | 152 +- .../attestations_tuf_root.generated.json | 5 + .../trusted_enclave_releases.generated.json | 17 - sdk/rust/src/attestation.rs | 2 +- sdk/rust/src/client.rs | 814 +- sdk/rust/src/error.rs | 27 + sdk/rust/src/lib.rs | 6 +- sdk/rust/src/trusted_release.rs | 9574 +++++++++++++++-- sdk/rust/tests/attestation.rs | 29 +- sdk/rust/tests/common/mod.rs | 42 +- .../fixtures/cosign-v3-blob.sigstore.json | 1 + sdk/rust/tests/pcr_environment.rs | 31 - .../update-trusted-enclave-releases.mjs | 656 -- .../update-trusted-enclave-releases.test.mjs | 106 - .../lib/attestation-tuf-root.generated.json | 5 + sdk/src/lib/attestation.ts | 56 +- sdk/src/lib/attestationForView.ts | 9 +- sdk/src/lib/attestationTuf.ts | 3672 +++++++ sdk/src/lib/getAttestation.ts | 34 +- sdk/src/lib/index.ts | 8 + sdk/src/lib/pcr.ts | 465 +- .../lib/test/getAttestationSecurity.test.ts | 204 +- .../lib/test/integration/attestation.test.ts | 51 +- .../integration/attestationSession.test.ts | 7 +- .../test/integration/liveAttestation.test.ts | 2 +- sdk/src/lib/test/integration/pcr.test.ts | 1941 +++- sdk/src/lib/test/tufFixtures.ts | 544 + .../trusted-enclave-releases.generated.json | 17 - 52 files changed, 18409 insertions(+), 2574 deletions(-) create mode 100644 sdk/rust/assets/attestations_tuf_root.generated.json delete mode 100644 sdk/rust/assets/trusted_enclave_releases.generated.json create mode 100644 sdk/rust/tests/fixtures/cosign-v3-blob.sigstore.json delete mode 100644 sdk/rust/tests/pcr_environment.rs delete mode 100644 sdk/scripts/update-trusted-enclave-releases.mjs delete mode 100644 sdk/scripts/update-trusted-enclave-releases.test.mjs create mode 100644 sdk/src/lib/attestation-tuf-root.generated.json create mode 100644 sdk/src/lib/attestationTuf.ts create mode 100644 sdk/src/lib/test/tufFixtures.ts delete mode 100644 sdk/src/lib/trusted-enclave-releases.generated.json 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 12d3b9542..5850be810 100644 --- a/.github/workflows/sdk-typescript.yml +++ b/.github/workflows/sdk-typescript.yml @@ -67,7 +67,6 @@ jobs: bun audit --audit-level=high bun run format:check bun run build - bun run test:trusted-release-updater VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 \ VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=dev \ bun test \ diff --git a/README.md b/README.md index b29d58320..69cbb44a4 100644 --- a/README.md +++ b/README.md @@ -214,16 +214,26 @@ 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, publish and verify its tagged Sigstore -release evidence, then update the trusted-release snapshot in the OpenSecret -SDK. Maple does not maintain its own PCR allowlist: before key exchange, the SDK -requires the complete PCR0/PCR1/PCR2 tuple to match a release authorized for the -configured environment. Runtime clients do not fetch this policy from GitHub, -Sigstore, or Rekor. - -Until a snapshot-bearing backend release is reviewed and imported, this draft -integration remains fail-closed and unavailable for real enclave connections. -Keeping the in-tree TypeScript and Rust snapshots on the same reviewed release -policy is part of the rollout requirement. +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. +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/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 7e17f785c..6aa6c4b7e 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,19 +5677,26 @@ dependencies = [ "chacha20poly1305", "chrono", "ciborium", + "directories", "eventsource-stream", + "fs2", "futures", "hex", "hkdf 0.12.4", "http", + "jiff", "p256", "percent-encoding", "pin-project", + "regex", "reqwest 0.12.28", "ring", "serde", "serde_json", "sha2 0.10.9", + "sigstore-tuf", + "sigstore-verify", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", @@ -6964,7 +7054,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -7125,9 +7215,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 +7256,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 +7300,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7243,6 +7333,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 +7576,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 +7808,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 +9099,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 +9292,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 +10027,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 +11181,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 +11212,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/src/agent/provider.rs b/frontend/src-tauri/src/agent/provider.rs index b430eb1b6..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,6 +919,19 @@ fn map_opensecret_error_kind(error: opensecret::Error) -> ProviderError { ProviderError::NetworkError("The Maple network request failed".to_string()) } } + 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(_) => { @@ -968,6 +984,8 @@ 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", @@ -2271,6 +2289,54 @@ mod tests { } } + #[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/maple_api.rs b/frontend/src-tauri/src/maple_api.rs index a5ba54ee5..0295799f1 100644 --- a/frontend/src-tauri/src/maple_api.rs +++ b/frontend/src-tauri/src/maple_api.rs @@ -11,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")] diff --git a/frontend/src/routes/proof.tsx b/frontend/src/routes/proof.tsx index e1322799f..fc6da3a85 100644 --- a/frontend/src/routes/proof.tsx +++ b/frontend/src/routes/proof.tsx @@ -63,8 +63,8 @@ function ProofDisplay({

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

{parsedDocument.pcrs @@ -95,7 +95,7 @@ function ProofDisplay({

)}

- SDK snapshot:{" "} + TUF policy ID:{" "} {releaseValidation.snapshotId}

{releaseValidation.transparencyLog && ( @@ -559,9 +559,8 @@ function ProofFAQ() { enforce attestation before key exchange
  • - Release authorization: The pinned OpenSecret release workflow and - Sigstore trust roots used to generate the SDK's embedded snapshot are - controlled as documented + Release authorization: The SDK's embedded TUF root, protected + promotion workflow, and online TUF signing key are controlled as documented
  • Code: The authorized open-source enclave code does what it claims @@ -586,11 +585,12 @@ function ProofFAQ() { server code is open source . The document on this page is fetched live and authenticated against AWS's Nitro - root. The SDK then checks the full PCR0/PCR1/PCR2 tuple against an embedded snapshot - generated from a verified tagged-release manifest and Cosign bundle. You can separately - verify that release's Sigstore/Rekor evidence and rebuild the source to compare its - measurements. Sigstore does not by itself prove reproducibility or that an authorized - release is the newest. + 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; Rust clients also verify 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.

  • @@ -791,7 +791,7 @@ function Verify() { /> **Integration status:** the embedded release snapshots are intentionally -> empty until the first signed backend release is reviewed and imported. Remote -> handshakes therefore fail closed in this draft branch; no release is published -> by this change. +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/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/src/config.rs b/proxy/src/config.rs index 8801e2942..5d56e2407 100644 --- a/proxy/src/config.rs +++ b/proxy/src/config.rs @@ -18,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", @@ -38,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", @@ -111,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 diff --git a/proxy/src/proxy.rs b/proxy/src/proxy.rs index 7d1330fc8..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, + )) } } @@ -205,15 +221,21 @@ async fn create_client_with_auth( 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", @@ -247,10 +269,11 @@ pub(crate) async fn proxy_openai_request( 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, @@ -324,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, @@ -424,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>>>, } @@ -433,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()), } } @@ -440,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() @@ -458,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)], @@ -736,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( @@ -755,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/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/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/sdk/README.md b/sdk/README.md index ae64797c7..1e6a11596 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -30,10 +30,112 @@ for external users. For non-local endpoints, both SDKs require HTTPS, verify AWS Nitro attestation, and require the complete PCR0/PCR1/PCR2 tuple to match an environment-scoped -trusted-release snapshot before completing key exchange. The snapshot is -generated during an SDK update only after verifying the backend release -manifest, Cosign signing identity, and Rekor transparency-log evidence; runtime -clients do not fetch policy from GitHub, Sigstore, or Rekor. +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 + verifies the portable Sigstore bundle, exact builder identity, and log proof + 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) + compares PCR0 + PCR1 + PCR2 against one complete authenticated manifest + performs key exchange only after that tuple matches +``` + +The browser authenticates and exposes the bundle, Sigstore trusted-root, and +builder-policy target identities and digests as audit evidence. It does **not** +claim to cryptographically verify the Sigstore bundle: the official Sigstore JS +verifier and TUF client are Node-only, so full bundle verification remains a +release-promotion requirement. TUF supplies current authorization and rollback +protection; the immutable Sigstore record supplies build provenance. Neither is +a substitute for the other. + +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 enforces the TUF-authenticated certificate +issuer and identity expression as part of portable-bundle verification. + +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. + +Bundle and Sigstore trusted-root bodies are downloaded and hash-checked but are +not copied into local storage. A refresh may fall back only to 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, +builder-policy, 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. Mock attestation is limited to exact loopback development endpoints (plus the documented Android emulator alias in the Rust SDK). Do not weaken attestation, @@ -45,6 +147,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 @@ -107,6 +214,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 @@ -122,6 +232,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. diff --git a/sdk/bun.lock b/sdk/bun.lock index 1732ea5de..6e622fe90 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -26,7 +26,6 @@ "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", - "sigstore": "5.0.0", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", @@ -146,8 +145,6 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], - "@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=="], @@ -182,12 +179,6 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@npmcli/agent": ["@npmcli/agent@5.0.2", "", { "dependencies": { "agent-base": "^9.0.0", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "lru-cache": "^11.2.1", "socks-proxy-agent": "^10.0.0" } }, "sha512-EkzGmEsgbQ1rqWkRJe2P0oQHx/ylZozDUNPMXCklLuSFL3GY+QyEfBUjhjCsgGXzh4OGpnHvkboSQgczjP/jJg=="], - - "@npmcli/fs": ["@npmcli/fs@6.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-AheOs4swKka/XLtht6xxJDPezlQ7K2IYQ9Y8lST4JLDjnralnWuMM9AE2CdVcgQJ5omrXhsRzM7F7aYmeZBvKQ=="], - - "@npmcli/redact": ["@npmcli/redact@5.0.0", "", {}, "sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A=="], - "@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=="], "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], @@ -276,18 +267,6 @@ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.12", "", { "dependencies": { "@rushstack/terminal": "0.24.2", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-Vg2n24arSf7JvUNga2DMHYTbxnVq9L5OtVCp4Gfr8YC/kmL/bmdR8FQhGcpMzMYNY9Vdw3+TaimG11Sf+z1Tpw=="], - "@sigstore/bundle": ["@sigstore/bundle@5.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A=="], - - "@sigstore/core": ["@sigstore/core@4.0.1", "", {}, "sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ=="], - - "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.1", "", {}, "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g=="], - - "@sigstore/sign": ["@sigstore/sign@5.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^5.0.0", "@sigstore/core": "^4.0.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^16.0.0", "proc-log": "^7.0.0" } }, "sha512-DSFivqz9/i5AkwZ5fq0YdjaJlc4o1WeS2Zffon0kqtChx0vy4W9NOjkEet9bF2vkzOufX72eVH8kZBIGtcBp1w=="], - - "@sigstore/tuf": ["@sigstore/tuf@5.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^6.0.0" } }, "sha512-Zyqg9tcHps3uRAlKHLNmsW4ohsUZAjb9G+31r7lg0ICh/JOcadzmJsIRdjKljlRHpaR0K4aJ2kXXIdywdcdMlA=="], - - "@sigstore/verify": ["@sigstore/verify@4.1.2", "", { "dependencies": { "@sigstore/bundle": "^5.0.0", "@sigstore/core": "^4.0.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-BfD9eLrz3A/DG58aSgfgZYmIR6V9Yw96QVN/frtu2bEH7ctSTk2TDvHU12un3JsDPBTqTNkqkIkucjxljpOFqQ=="], - "@stablelib/aead": ["@stablelib/aead@2.0.0", "", {}, "sha512-U/RMANRxbT/ahIpYsPSiFwDFNjADHdnCFfmo09MO1ai2XmerPAOPtMl0qmX7XVvygnACC6ijKDyHBoT2rGyElg=="], "@stablelib/base64": ["@stablelib/base64@2.0.1", "", {}, "sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ=="], @@ -308,10 +287,6 @@ "@stablelib/wipe": ["@stablelib/wipe@2.0.1", "", {}, "sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg=="], - "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], - - "@tufjs/models": ["@tufjs/models@5.0.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.2.1" } }, "sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ=="], - "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -376,8 +351,6 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -402,8 +375,6 @@ "bun-types": ["bun-types@1.1.34", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-br5QygTEL/TwB4uQOb96Ky22j4Gq2WxWH/8Oqv20fk5HagwKXo/akB+LiYgSfzexCt6kkcUaVm+bKiPl71xPvw=="], - "cacache": ["cacache@21.0.1", "", { "dependencies": { "@npmcli/fs": "^6.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^14.0.0" } }, "sha512-pTwz/uj3Jyp6WXdJ6fWhR+7LVxVs6RyroQSn7KJwHsSxXuyGSp0pcMVcwSwTpCFq1X2YG8QBe0W+vN+cr0SwzA=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], @@ -490,16 +461,12 @@ "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], - "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], @@ -512,14 +479,6 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], - "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - - "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], - - "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], - - "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -528,8 +487,6 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], - "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -574,24 +531,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "make-fetch-happen": ["make-fetch-happen@16.0.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^5.0.0", "@npmcli/redact": "^5.0.0", "cacache": "^21.0.0", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^6.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^7.0.0", "ssri": "^14.0.0" } }, "sha512-uUv1yxHzaKVVEPfcFeGSNov/Cehjv08ovlY8ImTljgL7Q+SiA0dAYLQ6SYVa2kkKqNj4Y3aZEI7xv2teadie0A=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], - - "minipass-fetch": ["minipass-fetch@6.0.0", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-AWI8bKapGmgx/J0E6IGYSKj8TiHebZkmKWSs8raPSw8KXwgEAJ+Bw3+LSdXHR6T/RHKAWCOYk2MiLrYluaUU6w=="], - - "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], - - "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], - - "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], - - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -602,8 +543,6 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], @@ -614,8 +553,6 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "p-map": ["p-map@7.0.6", "", {}, "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -626,8 +563,6 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -642,10 +577,6 @@ "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], - "proc-log": ["proc-log@7.0.0", "", {}, "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg=="], - - "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -668,30 +599,18 @@ "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "sigstore": ["sigstore@5.0.0", "", { "dependencies": { "@sigstore/bundle": "^5.0.0", "@sigstore/core": "^4.0.0", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^5.0.0", "@sigstore/tuf": "^5.0.0", "@sigstore/verify": "^4.0.0" } }, "sha512-hJqJfoG/e4qFQaauQL00c6J6FrHLBGKtkFvW3JbTSIEFOhLrSjdSM/gWd/yUOfYo/gsERehTXGC1VZWX+9X4Dg=="], - - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], - - "socks-proxy-agent": ["socks-proxy-agent@10.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "ssri": ["ssri@14.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-jQxKI0yx0ZnTKrqjKkLDV2DXkBQn3k49JVmVqDGcDwKDtGDbImD/GXsq04KD0VVzCQQ9wZJYal3RwR1GzWTSow=="], - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -708,8 +627,6 @@ "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], - "tuf-js": ["tuf-js@6.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ=="], - "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -758,18 +675,12 @@ "@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - - "@npmcli/fs/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@rushstack/node-core-library/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@tufjs/models/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -780,48 +691,26 @@ "@vue/language-core/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "cacache/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@microsoft/api-extractor/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "@tufjs/models/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=="], "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "@microsoft/api-extractor/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@tufjs/models/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/sdk/docs/PLATFORM.md b/sdk/docs/PLATFORM.md index 56ea150e0..d534ffb12 100644 --- a/sdk/docs/PLATFORM.md +++ b/sdk/docs/PLATFORM.md @@ -351,7 +351,7 @@ function PlatformManagement() { ### Attestation Verification -- `pcrConfig`: Selects the `"prod"` or `"dev"` trusted-release environment enforced before non-loopback session establishment. The complete PCR0/PCR1/PCR2 tuple must match the SDK's embedded, Sigstore-verified release snapshot; callers cannot add custom roots or runtime history URLs. +- `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 and requires the complete PCR0/PCR1/PCR2 tuple to match one active manifest. Callers cannot add custom roots or runtime history URLs. The browser validates TUF authorization but does not claim to reverify the portable Sigstore bundle; full bundle and builder-policy verification is a release-promotion boundary. - `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. diff --git a/sdk/flake.lock b/sdk/flake.lock index 0ca46c9ca..1ba62004d 100644 --- a/sdk/flake.lock +++ b/sdk/flake.lock @@ -55,8 +55,7 @@ "bun-nixpkgs": "bun-nixpkgs", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay", - "sigstore-nixpkgs": "sigstore-nixpkgs" + "rust-overlay": "rust-overlay" } }, "rust-overlay": { @@ -79,22 +78,6 @@ "type": "github" } }, - "sigstore-nixpkgs": { - "locked": { - "lastModified": 1784497964, - "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", - "type": "github" - } - }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/sdk/flake.nix b/sdk/flake.nix index 9935af454..7f92c38e5 100644 --- a/sdk/flake.nix +++ b/sdk/flake.nix @@ -6,8 +6,6 @@ # Keep Bun aligned with package.json and CI without advancing the SDK's # older Node/Rust/system package set. Update all three pins together. bun-nixpkgs.url = "github:NixOS/nixpkgs/5912c1772a44e31bf1c63c0390b90501e5026886"; - # Keep Sigstore's newer Node requirement isolated from the SDK toolchain. - sigstore-nixpkgs.url = "github:NixOS/nixpkgs/241313f4e8e508cb9b13278c2b0fa25b9ca27163"; flake-utils.url = "github:numtide/flake-utils"; rust-overlay = { url = "github:oxalica/rust-overlay"; @@ -15,40 +13,14 @@ }; }; - outputs = { self, nixpkgs, bun-nixpkgs, sigstore-nixpkgs, flake-utils, rust-overlay }: + outputs = { self, nixpkgs, bun-nixpkgs, flake-utils, rust-overlay }: flake-utils.lib.eachDefaultSystem (system: let overlays = [ rust-overlay.overlays.default ]; pkgs = import nixpkgs { inherit system overlays; }; bunPkgs = import bun-nixpkgs { inherit system; }; - sigstorePkgs = import sigstore-nixpkgs { inherit system; }; sdkBun = assert bunPkgs.bun.version == "1.3.5"; bunPkgs.bun; - cosignPlatforms = { - x86_64-linux = "linux-amd64"; - aarch64-linux = "linux-arm64"; - x86_64-darwin = "darwin-amd64"; - aarch64-darwin = "darwin-arm64"; - }; - cosignHashes = { - x86_64-linux = "sha256-92Iu088i5V4a5jd8CAl5/3eiLamYHBHfIiouREmR588="; - aarch64-linux = "sha256-kOeuC139YPIIFrUsASrd9/wFXrzHvqTOgcQoyoUYwwI="; - x86_64-darwin = "sha256-rNGA+LAVviUkDKM6vuih5WTrZc3xo87kclRW0tzrfaY="; - aarch64-darwin = "sha256-3sHD+AIyCxnC+88tx7z7PyWOHBgaBGwjoaB0vfky8Qo="; - }; - cosign_3_1_2 = pkgs.stdenvNoCC.mkDerivation { - pname = "cosign"; - version = "3.1.2"; - src = pkgs.fetchurl { - url = "https://github.com/sigstore/cosign/releases/download/v3.1.2/cosign-${cosignPlatforms.${system}}"; - hash = cosignHashes.${system}; - }; - dontUnpack = true; - installPhase = '' - install -Dm755 "$src" "$out/bin/cosign" - ''; - }; - # Try to use rust-toolchain.toml if it exists, otherwise use stable rust = if builtins.pathExists ./rust-toolchain.toml then pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml @@ -57,8 +29,7 @@ commonInputs = with pkgs; [ # TypeScript/JavaScript tooling sdkBun - sigstorePkgs.nodejs - cosign_3_1_2 + nodejs_20 nodePackages.typescript nodePackages.typescript-language-server diff --git a/sdk/package.json b/sdk/package.json index fbcbae539..0d859e4e8 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -22,9 +22,7 @@ "pack": "bun run build && bun pm pack", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", - "test": "bun test --timeout 30000", - "test:trusted-release-updater": "node --test scripts/update-trusted-enclave-releases.test.mjs", - "update:trusted-releases": "node scripts/update-trusted-enclave-releases.mjs" + "test": "bun test --timeout 30000" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" @@ -51,7 +49,6 @@ "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", - "sigstore": "5.0.0", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", diff --git a/sdk/rust/.env.example b/sdk/rust/.env.example index 81172024f..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 -# Trusted-release environment: prod (default) or dev -VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT=prod # Test credentials VITE_TEST_EMAIL=your-email@example.com diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index ee58ebee1..49bf8e820 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,28 @@ dependencies = [ "chacha20poly1305", "chrono", "ciborium", + "directories", "dotenvy", "eventsource-stream", + "fs2", "futures", "hex", "hkdf", "http", + "jiff", "p256", "percent-encoding", "pin-project", "pretty_assertions", - "reqwest", + "regex", + "reqwest 0.12.28", "ring", "serde", "serde_json", "sha2", + "sigstore-tuf", + "sigstore-verify", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-test", @@ -1294,6 +1590,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 +1631,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 +1692,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 +1721,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 +1751,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 +1817,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -1511,12 +1853,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 +1886,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 +1905,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 +1935,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 +2022,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 +2074,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1682,6 +2102,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 +2145,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 +2163,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 +2238,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 +2261,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 +2318,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 +2587,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 +2602,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 +2719,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 +2835,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 +2944,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 +3013,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 +3119,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 +3316,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 +3340,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 +3371,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..9bc84f6ed 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,9 @@ sha2 = "0.10" base64 = "0.22" ring = "0.17" # For certificate validation hex = "0.4" # For debug output +regex = "1.11" +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 +48,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 4f5a1aaf3..1fba52cf5 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,6 +33,9 @@ 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 @@ -56,19 +65,118 @@ async fn main() -> Result<()> { ``` Production clients verify both AWS Nitro authenticity and an atomic -PCR0/PCR1/PCR2 tuple from the SDK's offline, Sigstore-verified release -snapshot before key exchange. The convenience constructors recognize only the -SDK's exact official origins. A custom HTTPS origin must use -`new_with_attestation_policy` (or the API-key equivalent) with an explicit -`TrustedReleasePolicy`; unknown remote origins never inherit production trust. - -The checked-in snapshot is intentionally empty until a tagged backend release -is verified and imported. In that staging state, real handshakes return -`UnreleasedAttestationPolicy` rather than falling back to GitHub PCR histories. -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. +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 TUF-authenticated builder issuer and certificate-identity expression. +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. + +The authenticated builder target also carries `workflowName` and +`workflowTrigger`. Release promotion validates those and the GitHub certificate +workflow-ref and workflow-SHA extension claims. The Rust verifier currently +enforces the cryptographically bound certificate identity, issuer, repository +linkage, signature, and log proofs; `sigstore-verify` does not expose those +GitHub-specific extensions for an additional runtime comparison. ## Inference APIs @@ -213,23 +321,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_ATTESTATION_ENVIRONMENT=prod VITE_TEST_CLIENT_ID=your-client-id-uuid ``` -Production is the default when `VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT` is omitted. -Set it to `dev` 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/assets/trusted_enclave_releases.generated.json b/sdk/rust/assets/trusted_enclave_releases.generated.json deleted file mode 100644 index 4383ab52d..000000000 --- a/sdk/rust/assets/trusted_enclave_releases.generated.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "policy": { - "oidcIssuer": "https://token.actions.githubusercontent.com", - "sourceRepository": "OpenSecretCloud/opensecret", - "sourceRepositoryId": 921901924, - "sourceRepositoryOwnerId": 185423582, - "workflow": { - "environment": "production-release", - "name": "Nitro EIF Release", - "path": ".github/workflows/release-nitro-eif.yml", - "trigger": "workflow_dispatch" - } - }, - "releases": [], - "schema": "https://opensecret.cloud/sdk/trusted-enclave-releases/v1", - "snapshotId": "f5caf5bcb6abcdae2bac8cde92ce2d3722afc65c9e7bd39c9c5a1f2ad7780052" -} diff --git a/sdk/rust/src/attestation.rs b/sdk/rust/src/attestation.rs index 01d5cf795..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 `TrustedReleasePolicy` 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 948026bcc..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}, + error::{Error, InferenceTimeoutPhase, Result}, session::SessionManager, - trusted_release::{AttestationEnvironment, TrustedReleasePolicy}, + 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, - trusted_release_policy: TrustedReleasePolicy, + trusted_release_manager: Arc, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -500,49 +571,57 @@ fn default_attestation_environment(base_url: &str) -> Result Result<()> { if let Some(expected) = official_attestation_environment(base_url)? { - if policy.environment() != expected.as_str() { - return Err(Error::Configuration(format!( - "Attestation environment '{}' is not allowed for this official origin; expected '{}'", - policy.environment(), - expected.as_str() - ))); - } + manager.validate_official_trust_domain(expected)?; } Ok(()) } impl OpenSecretClient { - /// Construct a client using the embedded trusted-release snapshot selected - /// by the exact official origin. + /// Construct a client using the dynamic TUF channel selected by the exact + /// official origin. pub fn new(base_url: impl Into) -> Result { let base_url = base_url.into(); let environment = default_attestation_environment(&base_url)?; - Self::new_with_attestation_policy(base_url, TrustedReleasePolicy::embedded(environment)?) + Self::new_with_trusted_release_manager( + base_url, + TrustedReleaseManager::official(environment)?, + ) + } + + /// Construct a client with an explicit TUF repository and bootstrap root. + pub fn new_with_attestation_config( + base_url: impl Into, + config: TrustedReleaseConfig, + ) -> Result { + Self::new_with_trusted_release_manager( + base_url, + Arc::new(TrustedReleaseManager::new(config)?), + ) } - /// Construct a client with an explicit offline trusted-release policy. - pub fn new_with_attestation_policy( + /// Construct a client with a caller-shared dynamic policy manager. + pub fn new_with_trusted_release_manager( base_url: impl Into, - trusted_release_policy: TrustedReleasePolicy, + trusted_release_manager: Arc, ) -> Result { let base_url = base_url.into(); let use_mock = uses_mock_attestation(&base_url)?; - validate_official_origin_environment(&base_url, &trusted_release_policy)?; + validate_official_origin_trust_domain(&base_url, &trusted_release_manager)?; Ok(Self { client: Client::new(), @@ -550,31 +629,45 @@ impl OpenSecretClient { session_manager: SessionManager::new(), refresh_lock: Mutex::new(()), use_mock_attestation: use_mock, - trusted_release_policy, + trusted_release_manager, }) } - /// Construct an API-key client using the embedded trusted-release snapshot - /// selected by the exact official origin. + /// 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 { let base_url = base_url.into(); let environment = default_attestation_environment(&base_url)?; - Self::new_with_api_key_and_attestation_policy( + Self::new_with_api_key_and_trusted_release_manager( + base_url, + api_key, + TrustedReleaseManager::official(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, + config: TrustedReleaseConfig, + ) -> Result { + Self::new_with_api_key_and_trusted_release_manager( base_url, api_key, - TrustedReleasePolicy::embedded(environment)?, + Arc::new(TrustedReleaseManager::new(config)?), ) } - /// Construct an API-key client with an explicit offline trusted-release policy. - pub fn new_with_api_key_and_attestation_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, - trusted_release_policy: TrustedReleasePolicy, + trusted_release_manager: Arc, ) -> Result { let base_url = base_url.into(); let use_mock = uses_mock_attestation(&base_url)?; - validate_official_origin_environment(&base_url, &trusted_release_policy)?; + validate_official_origin_trust_domain(&base_url, &trusted_release_manager)?; Ok(Self { client: Client::new(), @@ -582,7 +675,7 @@ impl OpenSecretClient { session_manager: SessionManager::new_with_api_key(api_key), refresh_lock: Mutex::new(()), use_mock_attestation: use_mock, - trusted_release_policy, + trusted_release_manager, }) } @@ -595,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 } @@ -619,12 +713,31 @@ impl OpenSecretClient { /// /// 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<()> { - self.trusted_release_policy.verify_attestation(&doc)?; + 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 } @@ -984,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() { @@ -1011,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), @@ -2874,6 +3060,23 @@ 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], @@ -3157,6 +3360,55 @@ mod tests { 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() { @@ -3181,11 +3433,16 @@ mod tests { .mount(&mock_server) .await; - let production_policy = - TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); - let client = - OpenSecretClient::new_with_attestation_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(); @@ -3199,6 +3456,73 @@ mod tests { mock_server.verify().await; } + #[tokio::test] + 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(0) + .mount(&mock_server) + .await; + + 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(), + Arc::clone(&manager), + ) + .unwrap(); + 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_with_policy( + &Uuid::new_v4().to_string(), + synthetic_verified_attestation(DEVELOPMENT_PCR0), + &held, + ) + .await + .unwrap_err(); + assert!(matches!(error, Error::TrustedReleasePolicy(_))); + mock_server.verify().await; + } + #[test] fn mock_attestation_uses_the_parsed_host_not_url_substrings() { for url in [ @@ -3206,9 +3530,16 @@ mod tests { "https://example.com/localhost", "https://example.com/127.0.0.1", ] { - let policy = - TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); - let client = OpenSecretClient::new_with_attestation_policy(url, policy).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}"); } @@ -3225,11 +3556,18 @@ mod tests { ); } - let policy = TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); + let policy = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + Vec::new(), + ); assert!( - !OpenSecretClient::new_with_attestation_policy("https://localhost:3000", policy) - .unwrap() - .use_mock_attestation + !OpenSecretClient::new_with_trusted_release_manager( + "https://localhost:3000", + TrustedReleaseManager::fixed_for_test(policy), + ) + .unwrap() + .use_mock_attestation ); } @@ -3259,12 +3597,18 @@ mod tests { ); } else { assert!(client.is_err()); - let policy = - TrustedReleasePolicy::embedded(AttestationEnvironment::Production).unwrap(); + let policy = crate::trusted_release::TrustedReleasePolicy::for_test( + AttestationEnvironment::Production, + 1, + Vec::new(), + ); assert!( - !OpenSecretClient::new_with_attestation_policy("https://10.0.2.2:3000", policy) - .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 ); } } @@ -4333,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; @@ -4849,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]; diff --git a/sdk/rust/src/error.rs b/sdk/rust/src/error.rs index 9325df79f..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}")] @@ -25,6 +43,15 @@ pub enum Error { #[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 a5838aba2..509b8cbb5 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -9,7 +9,9 @@ pub mod trusted_release; pub mod types; pub use client::{InferenceRequest, InferenceResponse, OpenSecretClient, OpenSecretResponseBody}; -pub use error::{Error, Result}; +pub use error::{Error, InferenceTimeoutPhase, Result}; pub use push::*; -pub use trusted_release::{AttestationEnvironment, TrustedReleasePolicy}; +pub use trusted_release::{ + AttestationEnvironment, TrustedReleaseConfig, TrustedReleaseManager, TrustedReleasePolicy, +}; pub use types::*; diff --git a/sdk/rust/src/trusted_release.rs b/sdk/rust/src/trusted_release.rs index 136e93d16..eea08d22b 100644 --- a/sdk/rust/src/trusted_release.rs +++ b/sdk/rust/src/trusted_release.rs @@ -1,45 +1,97 @@ -//! Offline trust policy for OpenSecret Nitro enclave releases. +//! Dynamically refreshed trust policy for OpenSecret Nitro enclave releases. //! -//! The generated snapshot embedded by this module is an output of the SDK's -//! Sigstore verification/update tool. Runtime clients never fetch release -//! metadata from GitHub or query Rekor. They accept an attestation only when -//! its complete PCR0/PCR1/PCR2 tuple occurs in the snapshot for the explicitly -//! selected environment. +//! 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 regex::Regex; +use reqwest::{redirect::Policy as RedirectPolicy, Client, StatusCode, Url}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use sha2::{Digest, Sha256}; -use std::collections::HashSet; - -const SNAPSHOT_SCHEMA: &str = "https://opensecret.cloud/sdk/trusted-enclave-releases/v1"; -const MANIFEST_SCHEMA: &str = "https://opensecret.cloud/attestations/nitro-eif-release/v1"; -const EXPECTED_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; -const EXPECTED_SOURCE_REPOSITORY: &str = "OpenSecretCloud/opensecret"; -const EXPECTED_SOURCE_REPOSITORY_ID: u64 = 921_901_924; -const EXPECTED_SOURCE_REPOSITORY_OWNER_ID: u64 = 185_423_582; -const EXPECTED_WORKFLOW_PATH: &str = ".github/workflows/release-nitro-eif.yml"; -const EXPECTED_WORKFLOW_NAME: &str = "Nitro EIF Release"; -const EXPECTED_WORKFLOW_TRIGGER: &str = "workflow_dispatch"; -const EXPECTED_WORKFLOW_ENVIRONMENT: &str = "production-release"; -const EXPECTED_EIF_MEDIA_TYPE: &str = "application/vnd.aws.nitro.eif"; +use sigstore_tuf::{ + transport::FetchFuture, MetadataStore, Repository, StoreRepository, Updater, UpdaterConfig, +}; +use sigstore_verify::{ + trust_root::TrustedRoot as SigstoreTrustedRoot, + types::{Bundle, SignatureContent}, + VerificationPolicy, Verifier, +}; +use std::{ + 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 BUILDER_POLICY_SCHEMA: &str = + "https://attestations.trymaple.ai/schemas/sigstore-builder-policy/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_BUILDERS: usize = 32; +const MAX_IDENTITY_REGEXP_BYTES: usize = 2_048; +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_BUILDER_POLICY_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_RELEASE_SNAPSHOT: &str = - include_str!("../assets/trusted_enclave_releases.generated.json"); +const EMBEDDED_TUF_ROOT: &[u8] = include_bytes!("../assets/attestations_tuf_root.generated.json"); -/// Signed release environment authorized by an attestation policy. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +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", @@ -48,283 +100,137 @@ impl AttestationEnvironment { } } -/// A validated, immutable set of trusted enclave measurements for one -/// deployment environment. -/// -/// Constructing a custom policy is deliberately explicit: callers must provide -/// a snapshot in the same strict format as the generated production asset and -/// select the environment it is allowed to authorize. -#[derive(Clone, Debug)] -pub struct TrustedReleasePolicy { - expected_environment: String, - snapshot_id: String, - releases: Vec, +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)] -struct TrustedRelease { - tag: String, - pcr0: [u8; SHA384_BYTES_LEN], - pcr1: [u8; SHA384_BYTES_LEN], - pcr2: [u8; SHA384_BYTES_LEN], -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ReleaseSnapshot { - schema: String, - policy: SnapshotPolicy, - snapshot_id: String, - releases: Vec, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotPolicy { - oidc_issuer: String, - source_repository: String, - source_repository_id: u64, - source_repository_owner_id: u64, - workflow: SnapshotWorkflow, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotWorkflow { - path: String, - name: String, - trigger: String, - environment: String, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotRelease { - manifest_sha256: String, - bundle_sha256: String, - signer: SnapshotSigner, - transparency_log: SnapshotTransparencyLog, - manifest: ReleaseManifest, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotSigner { - oidc_issuer: String, - identity: String, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ReleaseManifest { - schema: String, - environment: String, - source: SnapshotSource, - release: SnapshotReleaseIdentity, - artifact: SnapshotArtifact, - measurements: SnapshotMeasurements, - build: SnapshotBuild, +pub struct TrustedReleaseConfig { + environment: AttestationEnvironment, + repository_url: String, + tuf_root: Arc<[u8]>, + cache_path: Option, } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotSource { - repository: String, - repository_id: u64, - owner_id: u64, - r#ref: String, - commit: String, -} +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, + }) + } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotReleaseIdentity { - tag: String, -} + /// 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()), + }) + } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotArtifact { - name: String, - media_type: String, - sha256: String, - size: u64, -} + /// 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 + } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotMeasurements { - algorithm: String, - required_pcrs: [u8; 3], - pcrs: SnapshotPcrs, -} + /// 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 + } -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct SnapshotPcrs { - #[serde(rename = "0")] - pcr0: String, - #[serde(rename = "1")] - pcr1: String, - #[serde(rename = "2")] - pcr2: String, + /// Selected release channel. + pub const fn environment(&self) -> AttestationEnvironment { + self.environment + } } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotTransparencyLog { - log_index: String, - log_id: String, +/// 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(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SnapshotBuild { - system: String, - flake_lock_sha256: String, - derivation: String, - workflow_run: String, +#[derive(Clone, Debug)] +struct TrustedRelease { + version: String, + pcr0: [u8; SHA384_BYTES_LEN], + pcr1: [u8; SHA384_BYTES_LEN], + pcr2: [u8; SHA384_BYTES_LEN], } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SnapshotIdInput<'a> { - schema: &'a str, - policy: &'a SnapshotPolicy, - releases: &'a [SnapshotRelease], -} +#[cfg(test)] +type TestReleaseTuple<'a> = (&'a str, [u8; 48], [u8; 48], [u8; 48]); impl TrustedReleasePolicy { - /// Loads the embedded snapshot and selects exactly one environment. - pub fn embedded(environment: AttestationEnvironment) -> Result { - let policy = Self::from_snapshot_json(EMBEDDED_RELEASE_SNAPSHOT, environment.as_str())?; - - let raw: ReleaseSnapshot = serde_json::from_str(EMBEDDED_RELEASE_SNAPSHOT) - .map_err(|error| Error::TrustedReleasePolicy(error.to_string()))?; - validate_official_policy(&raw.policy)?; - - Ok(policy) + /// The environment this policy is permitted to authorize. + pub fn environment(&self) -> &str { + self.environment.as_str() } - /// Loads the build-time snapshot for the production OpenSecret service. - /// - /// An empty, well-formed snapshot is accepted here so SDK artifacts can be - /// prepared before the first signed release is published. Verification - /// still fails closed with [`Error::UnreleasedAttestationPolicy`]. - pub fn embedded_production() -> Result { - Self::embedded(AttestationEnvironment::Production) + /// Monotonic channel sequence authenticated by TUF. + pub const fn sequence(&self) -> u64 { + self.sequence } - /// Loads the build-time snapshot for an explicitly selected development - /// OpenSecret enclave. - pub fn embedded_development() -> Result { - Self::embedded(AttestationEnvironment::Development) + /// SHA-256 of the exact authenticated channel bytes. + pub fn policy_id(&self) -> &str { + &self.policy_id } - /// Validates a generated snapshot and binds it to exactly one environment. - /// - /// This is intended for explicitly configured development or self-hosted - /// deployments. It does not weaken Nitro document verification. - pub fn from_snapshot_json( - snapshot_json: &str, - expected_environment: impl Into, - ) -> Result { - let expected_environment = expected_environment.into(); - validate_environment(&expected_environment)?; + /// 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()) + } - let raw: ReleaseSnapshot = serde_json::from_str(snapshot_json) - .map_err(|error| Error::TrustedReleasePolicy(error.to_string()))?; - if raw.schema != SNAPSHOT_SCHEMA { - return Err(policy_error(format!( - "unsupported snapshot schema '{}'", - raw.schema - ))); - } - validate_hex("snapshotId", &raw.snapshot_id, SHA256_HEX_LEN)?; - validate_snapshot_id(&raw)?; - validate_nonempty("policy.oidcIssuer", &raw.policy.oidc_issuer)?; - validate_nonempty("policy.sourceRepository", &raw.policy.source_repository)?; - if raw.policy.source_repository_id == 0 { + fn verify_attestation_at( + &self, + document: &AttestationDocument, + now: jiff::Timestamp, + ) -> Result<()> { + if now >= self.valid_until { return Err(policy_error( - "policy.sourceRepositoryId must be greater than zero", + "authenticated attestation policy metadata expired before PCR authorization", )); } - if raw.policy.source_repository_owner_id == 0 { - return Err(policy_error( - "policy.sourceRepositoryOwnerId must be greater than zero", - )); - } - validate_workflow_path(&raw.policy.workflow.path)?; - validate_nonempty("policy.workflow.name", &raw.policy.workflow.name)?; - validate_nonempty("policy.workflow.trigger", &raw.policy.workflow.trigger)?; - validate_nonempty( - "policy.workflow.environment", - &raw.policy.workflow.environment, - )?; - - let mut releases = Vec::new(); - let mut release_keys = HashSet::new(); - let mut manifest_hashes = HashSet::new(); - for release in raw.releases { - validate_release(&release, &raw.policy)?; - let release_key = format!( - "{}:{}", - release.manifest.environment, release.manifest.release.tag - ); - if !release_keys.insert(release_key.clone()) { - return Err(policy_error(format!( - "duplicate trusted release entry '{release_key}'" - ))); - } - if !manifest_hashes.insert(release.manifest_sha256.clone()) { - return Err(policy_error(format!( - "duplicate trusted release manifest '{}'", - release.manifest_sha256 - ))); - } - if release.manifest.environment != expected_environment { - continue; - } - - releases.push(TrustedRelease { - tag: release.manifest.release.tag, - pcr0: decode_pcr( - "manifest.measurements.pcrs.0", - &release.manifest.measurements.pcrs.pcr0, - )?, - pcr1: decode_pcr( - "manifest.measurements.pcrs.1", - &release.manifest.measurements.pcrs.pcr1, - )?, - pcr2: decode_pcr( - "manifest.measurements.pcrs.2", - &release.manifest.measurements.pcrs.pcr2, - )?, - }); - } - - Ok(Self { - expected_environment, - snapshot_id: raw.snapshot_id, - releases, - }) - } - - /// The environment this policy is permitted to authorize. - pub fn environment(&self) -> &str { - &self.expected_environment - } - - /// Stable identifier of the generated release snapshot. - pub fn snapshot_id(&self) -> &str { - &self.snapshot_id - } - - /// Verifies the complete PCR0/PCR1/PCR2 tuple atomically. - pub fn verify_attestation(&self, document: &AttestationDocument) -> Result<()> { if self.releases.is_empty() { return Err(Error::UnreleasedAttestationPolicy { - environment: self.expected_environment.clone(), + environment: self.environment.to_string(), }); } if document.digest != "SHA384" { @@ -337,7 +243,6 @@ impl TrustedReleasePolicy { let pcr0 = attestation_pcr(document, 0)?; let pcr1 = attestation_pcr(document, 1)?; let pcr2 = attestation_pcr(document, 2)?; - if self .releases .iter() @@ -346,653 +251,8696 @@ impl TrustedReleasePolicy { return Ok(()); } - let release_tags = self + let versions = self .releases .iter() - .map(|release| release.tag.as_str()) + .map(|release| release.version.as_str()) .collect::>() .join(", "); Err(Error::AttestationVerificationFailed(format!( - "PCR0/PCR1/PCR2 tuple is not present in trusted snapshot {} for environment '{}' (published releases: {})", - self.snapshot_id, self.expected_environment, release_tags + "PCR0/PCR1/PCR2 tuple is not active in authenticated channel {} for environment '{}' (active releases: {})", + self.policy_id, self.environment, versions ))) } -} -fn validate_snapshot_id(snapshot: &ReleaseSnapshot) -> Result<()> { - let input = SnapshotIdInput { - schema: &snapshot.schema, - policy: &snapshot.policy, - releases: &snapshot.releases, - }; - let actual = hex::encode(Sha256::digest(canonical_json_bytes(&input)?)); - if snapshot.snapshot_id != actual { - return Err(policy_error(format!( - "snapshotId '{}' does not match snapshot contents '{}'", - snapshot.snapshot_id, actual - ))); + #[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(), + } } - Ok(()) } -fn canonical_json_bytes(value: &T) -> Result> { - let value = serde_json::to_value(value) - .map_err(|error| policy_error(format!("failed to serialize trusted policy: {error}")))?; - let mut bytes = serde_json::to_vec_pretty(&sort_json(value)) - .map_err(|error| policy_error(format!("failed to serialize trusted policy: {error}")))?; - bytes.push(b'\n'); - Ok(bytes) +/// 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, } -fn sort_json(value: serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::Array(values) => { - serde_json::Value::Array(values.into_iter().map(sort_json).collect()) - } - serde_json::Value::Object(values) => { - let mut entries = values.into_iter().collect::>(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - let mut sorted = serde_json::Map::new(); - for (key, value) in entries { - sorted.insert(key, sort_json(value)); +#[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()) } - serde_json::Value::Object(sorted) + other => Self::Other(other.to_string()), } - value => value, } -} -fn validate_official_policy(policy: &SnapshotPolicy) -> Result<()> { - for (field, actual, expected) in [ - ( - "policy.oidcIssuer", - policy.oidc_issuer.clone(), - EXPECTED_OIDC_ISSUER.to_string(), - ), - ( - "policy.sourceRepository", - policy.source_repository.clone(), - EXPECTED_SOURCE_REPOSITORY.to_string(), - ), - ( - "policy.sourceRepositoryId", - policy.source_repository_id.to_string(), - EXPECTED_SOURCE_REPOSITORY_ID.to_string(), - ), - ( - "policy.sourceRepositoryOwnerId", - policy.source_repository_owner_id.to_string(), - EXPECTED_SOURCE_REPOSITORY_OWNER_ID.to_string(), - ), - ( - "policy.workflow.path", - policy.workflow.path.clone(), - EXPECTED_WORKFLOW_PATH.to_string(), - ), - ( - "policy.workflow.name", - policy.workflow.name.clone(), - EXPECTED_WORKFLOW_NAME.to_string(), - ), - ( - "policy.workflow.trigger", - policy.workflow.trigger.clone(), - EXPECTED_WORKFLOW_TRIGGER.to_string(), - ), - ( - "policy.workflow.environment", - policy.workflow.environment.clone(), - EXPECTED_WORKFLOW_ENVIRONMENT.to_string(), - ), - ] { - if actual != expected { - return Err(policy_error(format!( - "{field} must be '{expected}', got '{actual}'" - ))); + 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), } } - Ok(()) } -fn validate_release(release: &SnapshotRelease, policy: &SnapshotPolicy) -> Result<()> { - validate_hex( - "release.manifestSha256", - &release.manifest_sha256, - SHA256_HEX_LEN, - )?; - let canonical_manifest = canonical_json_bytes(&release.manifest)?; - let actual_manifest_sha256 = hex::encode(Sha256::digest(&canonical_manifest)); - if release.manifest_sha256 != actual_manifest_sha256 { - return Err(policy_error(format!( - "release manifestSha256 '{}' does not match embedded manifest '{}'", - release.manifest_sha256, actual_manifest_sha256 - ))); +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() } - validate_hex( - "release.bundleSha256", - &release.bundle_sha256, - SHA256_HEX_LEN, - )?; - if release.signer.oidc_issuer != policy.oidc_issuer { - return Err(policy_error(format!( - "release signer issuer '{}' does not match policy issuer '{}'", - release.signer.oidc_issuer, policy.oidc_issuer - ))); +} + +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, + }) } - let manifest = &release.manifest; - if manifest.schema != MANIFEST_SCHEMA { - return Err(policy_error(format!( - "unsupported release manifest schema '{}'", - manifest.schema - ))); + /// 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))) } - validate_environment(&manifest.environment)?; - if manifest.source.repository != policy.source_repository { - return Err(policy_error(format!( - "release source repository '{}' does not match policy repository '{}'", - manifest.source.repository, policy.source_repository - ))); + + /// 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)?)) } - if manifest.source.repository_id != policy.source_repository_id - || manifest.source.owner_id != policy.source_repository_owner_id - { - return Err(policy_error( - "release source repository IDs do not match snapshot policy", - )); + + /// Selected release channel. + pub const fn environment(&self) -> AttestationEnvironment { + self.config.environment } - validate_hex( - "release.manifest.source.commit", - &manifest.source.commit, - 40, - )?; - validate_release_tag(&manifest.release.tag)?; - let expected_ref = format!("refs/tags/{}", manifest.release.tag); - if manifest.source.r#ref != expected_ref { - return Err(policy_error(format!( - "release source ref '{}' does not match tag '{}'", - manifest.source.r#ref, manifest.release.tag - ))); + + 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(()) } - let expected_identity = format!( - "https://github.com/{}/{}@{}", - policy.source_repository, policy.workflow.path, manifest.source.r#ref - ); - if release.signer.identity != expected_identity { - return Err(policy_error(format!( - "release signer identity '{}' does not match '{}'", - release.signer.identity, expected_identity - ))); + + 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(), + } } - let expected_artifact_name = format!( - "opensecret-{}-{}.eif", - manifest.release.tag, manifest.environment - ); - if manifest.artifact.name != expected_artifact_name { - return Err(policy_error(format!( - "release artifact name '{}' does not match '{}'", - manifest.artifact.name, expected_artifact_name - ))); + /// 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 } - validate_artifact_name(&manifest.artifact.name)?; - if manifest.artifact.media_type != EXPECTED_EIF_MEDIA_TYPE { - return Err(policy_error(format!( - "release artifact media type must be '{EXPECTED_EIF_MEDIA_TYPE}'" - ))); + + /// 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(()) } - if manifest.artifact.size == 0 { - return Err(policy_error( - "release artifact size must be greater than zero", - )); + + async fn refresh_policy_inner(&self) -> Result { + self.refresh_policy_inner_with_verifier(&PortableBundleVerifier) + .await } - validate_hex( - "release.artifact.sha256", - &manifest.artifact.sha256, - SHA256_HEX_LEN, - )?; + + 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, + builder_policy_target: TargetReference, + 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 BuilderPolicy { + schema: String, + builders: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Builder { + certificate_identity_regexp: String, + certificate_oidc_issuer: String, + workflow_repository: String, + workflow_name: String, + workflow_trigger: 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], + builder: &Builder, + ) -> 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; + +impl BundleVerifier for PortableBundleVerifier { + fn verify( + &self, + manifest_bytes: &[u8], + bundle_bytes: &[u8], + trusted_root_bytes: &[u8], + builder: &Builder, + ) -> 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 = Bundle::from_json(bundle_json) + .map_err(|error| policy_error(format!("invalid Sigstore bundle: {error}")))?; + 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", + )); + } + if !matches!(&bundle.content, SignatureContent::MessageSignature(_)) { + return Err(policy_error( + "Sigstore bundle must contain a messageSignature", + )); + } + if bundle.verification_material.tlog_entries.is_empty() + || bundle + .verification_material + .tlog_entries + .iter() + .any(|entry| { + entry + .inclusion_proof + .as_ref() + .is_none_or(|proof| proof.checkpoint.is_empty()) + }) + { + return Err(policy_error( + "Sigstore bundle v0.3 must include a Merkle inclusion proof and signed checkpoint for every transparency-log entry", + )); + } + let policy = + VerificationPolicy::default().require_issuer(builder.certificate_oidc_issuer.clone()); + let result = Verifier::new(&trusted_root) + .verify(manifest_bytes, &bundle, &policy) + .map_err(|error| policy_error(format!("Sigstore verification failed: {error}")))?; + let identity = result + .identity + .ok_or_else(|| policy_error("Sigstore certificate has no identity"))?; + let identity_policy = compile_identity_policy(builder)?; + if !identity_policy + .find(&identity) + .is_some_and(|matched| matched.start() == 0 && matched.end() == identity.len()) + { + return Err(policy_error( + "Sigstore certificate identity does not satisfy the authenticated builder policy", + )); + } + 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 builders_bytes = get_bound_target( + &mut updater, + &channel.builder_policy_target.path, + &channel.builder_policy_target.sha256, + MAX_BUILDER_POLICY_BYTES, + now, + ) + .await + .map_err(prevent_fallback_after_channel)?; + let builders: BuilderPolicy = parse_json("builder policy", &builders_bytes)?; + validate_builder_policy(&builders)?; + + 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)?; + + // Parsing is necessary to select the TUF-authenticated builder entry, + // but none of these fields become trusted until verification succeeds + // over the exact raw manifest bytes below. + let manifest: ReleaseManifest = parse_json("release manifest", &manifest_bytes)?; + let builder = builders + .builders + .get(&manifest.build.builder_id) + .ok_or_else(|| { + RefreshFailure::Security(policy_error(format!( + "manifest references unknown builderId '{}'", + manifest.build.builder_id + ))) + })?; + validate_manifest(&manifest, &version, environment, builder)?; + bundle_verifier.verify( + &manifest_bytes, + &bundle_bytes, + &sigstore_root_bytes, + builder, + )?; + + 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.builder_policy_target.path, + Some(&channel.builder_policy_target.sha256), + MAX_BUILDER_POLICY_BYTES, + retained, + )?; + 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.builder_policy_target.path != "policy/builders.json" { + return Err(policy_error( + "builderPolicyTarget.path must be 'policy/builders.json'", + )); + } + 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( + "builderPolicyTarget.sha256", + &channel.builder_policy_target.sha256, + SHA256_HEX_LEN, + )?; + validate_hex( + "sigstoreTrustedRootTarget.sha256", + &channel.sigstore_trusted_root_target.sha256, + SHA256_HEX_LEN, + )?; + Ok(()) +} + +fn validate_builder_policy(policy: &BuilderPolicy) -> Result<()> { + if policy.schema != BUILDER_POLICY_SCHEMA { + return Err(policy_error(format!( + "unsupported builder policy schema '{}'", + policy.schema + ))); + } + if policy.builders.is_empty() || policy.builders.len() > MAX_BUILDERS { + return Err(policy_error(format!( + "builder policy must contain between 1 and {MAX_BUILDERS} builders" + ))); + } + for (id, builder) in &policy.builders { + validate_identifier("builder ID", id)?; + validate_https_url("certificateOidcIssuer", &builder.certificate_oidc_issuer)?; + validate_workflow_repository(&builder.workflow_repository)?; + validate_nonempty("workflowName", &builder.workflow_name)?; + validate_nonempty("workflowTrigger", &builder.workflow_trigger)?; + compile_identity_policy(builder)?; + } + Ok(()) +} + +fn compile_identity_policy(builder: &Builder) -> Result { + let value = &builder.certificate_identity_regexp; + if value.len() > MAX_IDENTITY_REGEXP_BYTES || !value.starts_with('^') || !value.ends_with('$') { + return Err(policy_error( + "certificateIdentityRegexp must be an anchored expression of at most 2048 bytes", + )); + } + Regex::new(value) + .map_err(|error| policy_error(format!("invalid certificateIdentityRegexp: {error}"))) +} + +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, + builder: &Builder, +) -> 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)?; + let source_uri = validate_https_url("source.uri", &manifest.source.uri)?; + let expected_source_path = format!("/{}", builder.workflow_repository); + let source_repository_path = source_uri + .path() + .strip_suffix(".git") + .unwrap_or(source_uri.path()); + if source_repository_path != expected_source_path { + return Err(policy_error( + "manifest source.uri does not match the authenticated builder repository", + )); + } + + 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( - "release measurement algorithm must be 'sha384'", + "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_workflow_repository(value: &str) -> Result<()> { + let parts = value.split('/').collect::>(); + if parts.len() != 2 + || parts.iter().any(|part| { + part.is_empty() + || matches!(*part, "." | "..") + || part.len() > 128 + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) + { + return Err(policy_error( + "workflowRepository must be an owner/name repository 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], + builder: &Builder, + ) -> 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"); + assert_eq!( + builder.certificate_oidc_issuer, + "https://token.actions.githubusercontent.com" + ); + 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_builder_digest: bool, + active_releases: bool, + ) -> (MemoryRepository, Vec) { + let key_pair = KeyPair::generate_ecdsa_p256().unwrap(); + build_policy_repository_generation( + timestamp_expires, + bad_builder_digest, + active_releases, + &key_pair, + 1, + if active_releases { 7 } else { 8 }, + true, + ) + } + + fn build_policy_repository_generation( + timestamp_expires: &str, + bad_builder_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_builder_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_builder_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 builder_path = "policy/builders.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://github.com/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": "opensecret-nitro-eif-github-actions", + "derivation": "eif-prod", + "flakeLockSha256": "c".repeat(64), + "runUri": "https://github.com/OpenSecretCloud/opensecret/actions/runs/1/attempts/1", + }, + })) + .unwrap(); + let bundle = b"fixture-bundle".to_vec(); + let builders = serde_json::to_vec(&json!({ + "schema": BUILDER_POLICY_SCHEMA, + "builders": { + "opensecret-nitro-eif-github-actions": { + "certificateIdentityRegexp": "^https://github[.]com/OpenSecretCloud/opensecret/[.]github/workflows/release-nitro-eif[.]yml@refs/tags/v1[.]2[.]3$", + "certificateOidcIssuer": "https://token.actions.githubusercontent.com", + "workflowRepository": "OpenSecretCloud/opensecret", + "workflowName": "Nitro EIF Release", + "workflowTrigger": "workflow_dispatch", + }, + }, + })) + .unwrap(); + let trusted_root = b"fixture-trusted-root".to_vec(); + let builder_digest = if bad_builder_digest { + "0".repeat(64) + } else { + sha256_hex(&builders) + }; + 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, + "builderPolicyTarget": { "path": builder_path, "sha256": builder_digest }, + "sigstoreTrustedRootTarget": { "path": trusted_root_path, "sha256": sha256_hex(&trusted_root) }, + "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), + (builder_path.to_string(), builders), + (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( + repository, + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + ) + .await + .unwrap(); + assert_eq!(policy.sequence(), 7); + assert!(policy.verify_attestation(&document(1, 2, 3)).is_ok()); + + let offline = resolve_policy( + StoreRepository::new(Arc::clone(&store)), + Arc::clone(&store), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + ) + .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(&document(1, 2, 3)), + 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(&document(1, 2, 3)), + 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) + ); } - if manifest.measurements.required_pcrs != [0, 1, 2] { - return Err(policy_error( - "release requiredPcrs must be exactly [0, 1, 2]", + + #[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(); } - decode_pcr( - "release.measurements.pcrs.0", - &manifest.measurements.pcrs.pcr0, - )?; - decode_pcr( - "release.measurements.pcrs.1", - &manifest.measurements.pcrs.pcr1, - )?; - decode_pcr( - "release.measurements.pcrs.2", - &manifest.measurements.pcrs.pcr2, - )?; - if release.transparency_log.log_index.is_empty() - || !release - .transparency_log - .log_index - .bytes() - .all(|byte| byte.is_ascii_digit()) - || (release.transparency_log.log_index.len() > 1 - && release.transparency_log.log_index.starts_with('0')) - { - return Err(policy_error( - "release transparency log index must be an unsigned decimal integer", + #[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( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + ) + .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( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: false }, + ) + .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( + repository, + Arc::new(SnapshotStore::default()), + &root, + AttestationEnvironment::Production, + now, + &FixtureBundleVerifier { fail: true }, + ) + .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 builder_identity_policy_must_be_anchored() { + let builder = Builder { + certificate_identity_regexp: "github".to_string(), + certificate_oidc_issuer: "https://token.actions.githubusercontent.com".to_string(), + workflow_repository: "OpenSecretCloud/opensecret".to_string(), + workflow_name: "Nitro EIF Release".to_string(), + workflow_trigger: "workflow_dispatch".to_string(), + }; + assert!(compile_identity_policy(&builder).is_err()); + } + + #[test] + fn builder_identifiers_and_oidc_issuers_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( + "certificateOidcIssuer", + "https://token.actions.githubusercontent.com" + ) + .is_ok()); + assert!(validate_https_url( + "certificateOidcIssuer", + "https://token.actions.githubusercontent.com?tenant=maple" + ) + .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() { + let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + let online_pair = KeyPair::generate_ecdsa_p256().unwrap(); + let (mut repository, _) = build_policy_repository_generation( + "2026-08-30T00:00:00Z", + 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); + } } - validate_hex( - "release.transparencyLog.logId", - &release.transparency_log.log_id, - SHA256_HEX_LEN, - )?; - if manifest.build.system != "nix" { - return Err(policy_error("release build system must be 'nix'")); - } - validate_hex( - "release.manifest.build.flakeLockSha256", - &manifest.build.flake_lock_sha256, - SHA256_HEX_LEN, - )?; - let expected_derivation = format!("eif-{}", manifest.environment); - if manifest.build.derivation != expected_derivation { - return Err(policy_error(format!( - "release build derivation '{}' does not match environment '{}'", - manifest.build.derivation, manifest.environment - ))); - } - validate_workflow_run(&manifest.build.workflow_run, &policy.source_repository)?; - Ok(()) -} + #[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); -fn validate_environment(environment: &str) -> Result<()> { - if matches!(environment, "prod" | "dev") { - Ok(()) - } else { - Err(policy_error(format!( - "unsupported attestation environment '{environment}'" - ))) - } -} + 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]); -fn validate_release_tag(tag: &str) -> Result<()> { - let Some(version) = tag.strip_prefix('v') else { - return Err(policy_error(format!( - "release tag '{tag}' is not a stable vMAJOR.MINOR.PATCH tag" - ))); - }; - let parts = version.split('.').collect::>(); - if parts.len() != 3 - || parts - .iter() - .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) - || parts - .iter() - .any(|part| part.len() > 1 && part.starts_with('0')) - { - return Err(policy_error(format!( - "release tag '{tag}' is not a stable vMAJOR.MINOR.PATCH tag" - ))); + 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")); } - Ok(()) -} -fn validate_workflow_path(path: &str) -> Result<()> { - validate_nonempty("policy.workflow.path", path)?; - if path.starts_with('/') - || path.contains('\\') - || path.split('/').any(|component| component == "..") - || !path.starts_with(".github/workflows/") - || !(path.ends_with(".yml") || path.ends_with(".yaml")) - { - return Err(policy_error(format!( - "invalid GitHub Actions workflow path '{path}'" - ))); + #[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")); } - Ok(()) -} -fn validate_artifact_name(name: &str) -> Result<()> { - validate_nonempty("release.artifact.name", name)?; - if name == "." || name == ".." || name.contains('/') || name.contains('\\') { - return Err(policy_error(format!( - "release artifact name '{name}' must be a file name" - ))); + #[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")); } - Ok(()) -} -fn validate_workflow_run(value: &str, repository: &str) -> Result<()> { - let url = reqwest::Url::parse(value) - .map_err(|error| policy_error(format!("invalid build workflowRun URL: {error}")))?; - let expected_prefix = format!("/{repository}/actions/runs/"); - let run_id = url - .path() - .strip_prefix(&expected_prefix) - .unwrap_or_default(); - let run_parts = run_id.split('/').collect::>(); - let valid_run_path = matches!( - run_parts.as_slice(), - [run, "attempts", attempt] - if is_positive_decimal(run) && is_positive_decimal(attempt) - ); - if url.scheme() != "https" - || url.host_str() != Some("github.com") - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || !valid_run_path - { - return Err(policy_error(format!( - "build workflowRun must be an exact GitHub Actions run-attempt URL for '{repository}'" - ))); + #[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); } - Ok(()) -} -fn is_positive_decimal(value: &str) -> bool { - !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) && !value.starts_with('0') -} + #[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(); -fn validate_nonempty(field: &str, value: &str) -> Result<()> { - if value.is_empty() || value.trim() != value { - Err(policy_error(format!("{field} must be a non-empty string"))) - } else { - Ok(()) + 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")); } -} -fn validate_hex(field: &str, value: &str, expected_len: usize) -> Result<()> { - if value.len() != expected_len - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(policy_error(format!( - "{field} must be exactly {expected_len} lowercase hexadecimal characters" - ))); + #[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")); } - 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"))); + #[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); } - bytes.try_into().map_err(|_| { - policy_error(format!( - "{field} must decode to exactly {SHA384_BYTES_LEN} bytes" - )) - }) -} -fn attestation_pcr(document: &AttestationDocument, index: usize) -> Result<[u8; SHA384_BYTES_LEN]> { - let value = document - .pcrs - .get(&index) - .ok_or_else(|| Error::AttestationVerificationFailed(format!("PCR{index} missing")))?; - value.as_slice().try_into().map_err(|_| { - Error::AttestationVerificationFailed(format!( - "PCR{index} must be exactly {SHA384_BYTES_LEN} bytes" - )) - }) -} + #[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); -fn policy_error(message: impl Into) -> Error { - Error::TrustedReleasePolicy(message.into()) -} + 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); -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - fn snapshot(releases: &str) -> String { - let releases: serde_json::Value = serde_json::from_str(releases).unwrap(); - let mut value = serde_json::json!({ - "schema": SNAPSHOT_SCHEMA, - "policy": { - "oidcIssuer": EXPECTED_OIDC_ISSUER, - "sourceRepository": EXPECTED_SOURCE_REPOSITORY, - "sourceRepositoryId": EXPECTED_SOURCE_REPOSITORY_ID, - "sourceRepositoryOwnerId": EXPECTED_SOURCE_REPOSITORY_OWNER_ID, - "workflow": { - "path": EXPECTED_WORKFLOW_PATH, - "name": EXPECTED_WORKFLOW_NAME, - "trigger": EXPECTED_WORKFLOW_TRIGGER, - "environment": EXPECTED_WORKFLOW_ENVIRONMENT - } - }, - "releases": releases - }); - let snapshot_id = hex::encode(Sha256::digest(canonical_json_bytes(&value).unwrap())); - value.as_object_mut().unwrap().insert( - "snapshotId".to_string(), - serde_json::Value::String(snapshot_id), - ); - String::from_utf8(canonical_json_bytes(&value).unwrap()).unwrap() - } - - fn release(tag: &str, environment: &str, values: [u8; 3]) -> String { - let mut value: serde_json::Value = serde_json::from_str(&format!( - r#"{{ - "manifestSha256": "{sha256}", - "bundleSha256": "{sha256}", - "signer": {{ - "oidcIssuer": "{EXPECTED_OIDC_ISSUER}", - "identity": "https://github.com/{EXPECTED_SOURCE_REPOSITORY}/{EXPECTED_WORKFLOW_PATH}@refs/tags/{tag}" - }}, - "transparencyLog": {{ - "logIndex": "42", - "logId": "{sha256}" - }}, - "manifest": {{ - "schema": "{MANIFEST_SCHEMA}", - "environment": "{environment}", - "source": {{ - "repository": "{EXPECTED_SOURCE_REPOSITORY}", - "repositoryId": {EXPECTED_SOURCE_REPOSITORY_ID}, - "ownerId": {EXPECTED_SOURCE_REPOSITORY_OWNER_ID}, - "ref": "refs/tags/{tag}", - "commit": "{commit}" - }}, - "release": {{ "tag": "{tag}" }}, - "artifact": {{ - "name": "opensecret-{tag}-{environment}.eif", - "mediaType": "{EXPECTED_EIF_MEDIA_TYPE}", - "sha256": "{sha256}", - "size": 123 - }}, - "measurements": {{ - "algorithm": "sha384", - "requiredPcrs": [0, 1, 2], - "pcrs": {{ - "0": "{pcr0}", - "1": "{pcr1}", - "2": "{pcr2}" - }} - }}, - "build": {{ - "system": "nix", - "flakeLockSha256": "{sha256}", - "derivation": "eif-{environment}", - "workflowRun": "https://github.com/{EXPECTED_SOURCE_REPOSITORY}/actions/runs/123456789/attempts/1" - }} - }} -}}"#, - sha256 = "b".repeat(SHA256_HEX_LEN), - commit = "c".repeat(40), - pcr0 = hex::encode([values[0]; SHA384_BYTES_LEN]), - pcr1 = hex::encode([values[1]; SHA384_BYTES_LEN]), - pcr2 = hex::encode([values[2]; SHA384_BYTES_LEN]), - )) + // 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(); - let manifest_sha256 = hex::encode(Sha256::digest( - canonical_json_bytes(&value["manifest"]).unwrap(), - )); - value["manifestSha256"] = serde_json::Value::String(manifest_sha256); - String::from_utf8(canonical_json_bytes(&value).unwrap()).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")); + } } - fn rehash_release(release: String) -> String { - let mut value: serde_json::Value = serde_json::from_str(&release).unwrap(); - let manifest_sha256 = hex::encode(Sha256::digest( - canonical_json_bytes(&value["manifest"]).unwrap(), - )); - value["manifestSha256"] = serde_json::Value::String(manifest_sha256); - String::from_utf8(canonical_json_bytes(&value).unwrap()).unwrap() + #[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")); } - fn document(values: [u8; 3]) -> AttestationDocument { - AttestationDocument { - module_id: "test".to_string(), - timestamp: 0, - digest: "SHA384".to_string(), - pcrs: HashMap::from([ - (0, vec![values[0]; SHA384_BYTES_LEN]), - (1, vec![values[1]; SHA384_BYTES_LEN]), - (2, vec![values[2]; SHA384_BYTES_LEN]), - ]), - certificate: Vec::new(), - cabundle: Vec::new(), - public_key: None, - user_data: None, - nonce: None, - } + #[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 accepts_complete_tuple_from_one_release() { - let policy = TrustedReleasePolicy::from_snapshot_json( - &snapshot(&format!("[{}]", release("v1.2.3", "prod", [1, 2, 3]))), - "prod", + 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; - policy.verify_attestation(&document([1, 2, 3])).unwrap(); + 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 rejects_pcrs_mixed_across_releases() { - let releases = format!( - "[{},{}]", - release("v1.2.3", "prod", [1, 2, 3]), - release("v1.2.4", "prod", [4, 5, 6]) + 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 policy = - TrustedReleasePolicy::from_snapshot_json(&snapshot(&releases), "prod").unwrap(); + 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 error = policy.verify_attestation(&document([1, 5, 3])).unwrap_err(); - assert!(matches!( - error, - Error::AttestationVerificationFailed(message) - if message.contains("PCR0/PCR1/PCR2 tuple") - )); + 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 binds_releases_to_selected_environment() { - let releases = format!( - "[{},{}]", - release("v1.2.3", "prod", [1, 2, 3]), - release("v1.2.3", "dev", [4, 5, 6]) + 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 policy = - TrustedReleasePolicy::from_snapshot_json(&snapshot(&releases), "prod").unwrap(); + 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")); + } - let error = policy.verify_attestation(&document([4, 5, 6])).unwrap_err(); - assert!(matches!(error, Error::AttestationVerificationFailed(_))); + #[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 empty_environment_fails_with_unreleased_policy_error() { - let policy = TrustedReleasePolicy::from_snapshot_json(&snapshot("[]"), "prod").unwrap(); + 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 = policy.verify_attestation(&document([1, 2, 3])).unwrap_err(); - assert!(matches!( - error, - Error::UnreleasedAttestationPolicy { environment } if environment == "prod" - )); + 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 rejects_missing_or_wrong_length_required_pcr() { - let policy = TrustedReleasePolicy::from_snapshot_json( - &snapshot(&format!("[{}]", release("v1.2.3", "prod", [1, 2, 3]))), - "prod", + 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 mut missing = document([1, 2, 3]); - missing.pcrs.remove(&1); - assert!(matches!( - policy.verify_attestation(&missing), - Err(Error::AttestationVerificationFailed(message)) if message == "PCR1 missing" - )); - let mut short = document([1, 2, 3]); - short.pcrs.insert(2, vec![3; SHA384_BYTES_LEN - 1]); - assert!(matches!( - policy.verify_attestation(&short), - Err(Error::AttestationVerificationFailed(message)) - if message.contains("PCR2 must be exactly") - )); + 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 rejects_unstable_tag_and_cross_record_ref() { - let unstable = release("v1.2.3-rc.1", "prod", [1, 2, 3]); - assert!(matches!( - TrustedReleasePolicy::from_snapshot_json(&snapshot(&format!("[{unstable}]")), "prod"), - Err(Error::TrustedReleasePolicy(message)) if message.contains("stable") - )); + 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() + ); + } - let wrong_ref = rehash_release( - release("v1.2.3", "prod", [1, 2, 3]).replace("refs/tags/v1.2.3", "refs/tags/v9.9.9"), + #[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!(matches!( - TrustedReleasePolicy::from_snapshot_json(&snapshot(&format!("[{wrong_ref}]")), "prod"), - Err(Error::TrustedReleasePolicy(message)) if message.contains("does not match tag") - )); + 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 rejects_all_zero_release_measurement() { - let zero_pcr = rehash_release(release("v1.2.3", "prod", [1, 2, 3]).replace( - &hex::encode([1; SHA384_BYTES_LEN]), - &hex::encode([0; SHA384_BYTES_LEN]), - )); + 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)), + }; - assert!(matches!( - TrustedReleasePolicy::from_snapshot_json(&snapshot(&format!("[{zero_pcr}]")), "prod"), - Err(Error::TrustedReleasePolicy(message)) if message.contains("must not be all zeroes") - )); + 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); + } + + fn cosign_fixture_builder(identity: &str) -> Builder { + Builder { + certificate_identity_regexp: format!("^{identity}$"), + certificate_oidc_issuer: "https://github.com/login/oauth".to_string(), + workflow_repository: "example/example".to_string(), + workflow_name: "fixture".to_string(), + workflow_trigger: "fixture".to_string(), + } } #[test] - fn accepts_exact_github_run_attempt_urls_only() { - validate_workflow_run( - "https://github.com/OpenSecretCloud/opensecret/actions/runs/123/attempts/2", - EXPECTED_SOURCE_REPOSITORY, + fn portable_bundle_verification_is_fully_local_and_identity_bound() { + 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"; + let builder = cosign_fixture_builder(r"w[.]vollprecht@gmail[.]com"); + PortableBundleVerifier + .verify(artifact, bundle, trusted_root, &builder) + .unwrap(); + + assert!(PortableBundleVerifier + .verify(b"tampered", bundle, trusted_root, &builder) + .is_err()); + assert!(PortableBundleVerifier + .verify( + artifact, + bundle, + trusted_root, + &cosign_fixture_builder("someone-else@example[.]com"), + ) + .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, + &builder, + ) + .is_err()); + } + + #[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(); - for invalid in [ - "https://github.com/OpenSecretCloud/opensecret/actions/runs/123", - "https://github.com/OpenSecretCloud/opensecret/actions/runs/123/jobs/2", - "https://github.com/OpenSecretCloud/opensecret/actions/runs/123/attempts/0", - "https://github.com/OpenSecretCloud/opensecret/actions/runs/123?attempt=2", - "https://example.com/OpenSecretCloud/opensecret/actions/runs/123", - ] { - assert!(validate_workflow_run(invalid, EXPECTED_SOURCE_REPOSITORY).is_err()); - } + 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 760d3e25b..3e3a077e0 100644 --- a/sdk/rust/tests/attestation.rs +++ b/sdk/rust/tests/attestation.rs @@ -1,8 +1,9 @@ mod common; -use opensecret::{AttestationEnvironment, Error, OpenSecretClient, Result, TrustedReleasePolicy}; +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,10 +57,7 @@ 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(()) @@ -75,13 +72,20 @@ async fn test_hosted_development_rejects_explicit_production_policy() -> Result< println!("Skipping hosted policy-separation test - running against localhost"); return Ok(()); } - if common::selected_pcr0_environment()? != AttestationEnvironment::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 production_policy = TrustedReleasePolicy::embedded(AttestationEnvironment::Production)?; - let error = match OpenSecretClient::new_with_attestation_policy(base_url, production_policy) { + 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, }; @@ -95,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 7413ea2f3..a37563f80 100644 --- a/sdk/rust/tests/common/mod.rs +++ b/sdk/rust/tests/common/mod.rs @@ -1,53 +1,19 @@ #![allow(dead_code)] -use opensecret::{AttestationEnvironment, Error, OpenSecretClient, Result, TrustedReleasePolicy}; -use std::env::{self, VarError}; - -const PCR_ENVIRONMENT_VARIABLE: &str = "VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT"; -const PCR_ENVIRONMENT_ERROR: &str = - "VITE_OPEN_SECRET_ATTESTATION_ENVIRONMENT must be either \"prod\" or \"dev\""; +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("prod") => Ok(AttestationEnvironment::Production), - Some("dev") => Ok(AttestationEnvironment::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_attestation_policy( - base_url, - TrustedReleasePolicy::embedded(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_attestation_policy( - base_url, - api_key, - TrustedReleasePolicy::embedded(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/pcr_environment.rs b/sdk/rust/tests/pcr_environment.rs deleted file mode 100644 index 0d29af5d1..000000000 --- a/sdk/rust/tests/pcr_environment.rs +++ /dev/null @@ -1,31 +0,0 @@ -mod common; - -use common::parse_pcr0_environment; -use opensecret::AttestationEnvironment; - -#[test] -fn pcr_environment_defaults_to_production() { - assert_eq!( - parse_pcr0_environment(None).unwrap(), - AttestationEnvironment::Production - ); -} - -#[test] -fn pcr_environment_accepts_exact_supported_values() { - assert_eq!( - parse_pcr0_environment(Some("prod")).unwrap(), - AttestationEnvironment::Production - ); - assert_eq!( - parse_pcr0_environment(Some("dev")).unwrap(), - AttestationEnvironment::Development - ); -} - -#[test] -fn pcr_environment_rejects_empty_differently_cased_and_unknown_values() { - for value in ["", "Prod", "production", "development", " dev "] { - assert!(parse_pcr0_environment(Some(value)).is_err()); - } -} diff --git a/sdk/scripts/update-trusted-enclave-releases.mjs b/sdk/scripts/update-trusted-enclave-releases.mjs deleted file mode 100644 index d9af8fbe1..000000000 --- a/sdk/scripts/update-trusted-enclave-releases.mjs +++ /dev/null @@ -1,656 +0,0 @@ -#!/usr/bin/env node - -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { verify } from "sigstore"; - -const SNAPSHOT_SCHEMA = "https://opensecret.cloud/sdk/trusted-enclave-releases/v1"; -const MANIFEST_SCHEMA = "https://opensecret.cloud/attestations/nitro-eif-release/v1"; -const BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json"; -const OIDC_ISSUER = "https://token.actions.githubusercontent.com"; -const SOURCE_REPOSITORY = "OpenSecretCloud/opensecret"; -const SOURCE_REPOSITORY_URI = `https://github.com/${SOURCE_REPOSITORY}`; -const SOURCE_REPOSITORY_ID = 921901924; -const SOURCE_REPOSITORY_OWNER_ID = 185423582; -const SOURCE_REPOSITORY_OWNER_URI = "https://github.com/OpenSecretCloud"; -const WORKFLOW_PATH = ".github/workflows/release-nitro-eif.yml"; -const WORKFLOW_NAME = "Nitro EIF Release"; -const WORKFLOW_TRIGGER = "workflow_dispatch"; -const WORKFLOW_ENVIRONMENT = "production-release"; -const REQUIRED_COSIGN_VERSION = [3, 1, 2]; - -const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const defaultOutputs = [ - resolve(projectRoot, "src/lib/trusted-enclave-releases.generated.json"), - resolve(projectRoot, "rust/assets/trusted_enclave_releases.generated.json") -]; - -const SNAPSHOT_POLICY = { - oidcIssuer: OIDC_ISSUER, - sourceRepository: SOURCE_REPOSITORY, - sourceRepositoryId: SOURCE_REPOSITORY_ID, - sourceRepositoryOwnerId: SOURCE_REPOSITORY_OWNER_ID, - workflow: { - path: WORKFLOW_PATH, - name: WORKFLOW_NAME, - trigger: WORKFLOW_TRIGGER, - environment: WORKFLOW_ENVIRONMENT - } -}; - -function usage() { - return `Usage: - node scripts/update-trusted-enclave-releases.mjs \\ - --manifest --bundle \\ - [--manifest <...> --bundle <...>] [--cosign ] [--output ...] - -Every desired trusted release must be supplied on each run. The updater verifies -each exact manifest byte sequence with both official sigstore-js and Cosign, -then atomically rewrites the TypeScript and Rust embedded snapshots. -`; -} - -function fail(message) { - throw new Error(message); -} - -function isPlainObject(value) { - return ( - value !== null && - typeof value === "object" && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ); -} - -function assertPlainObject(value, path) { - if (!isPlainObject(value)) { - fail(`${path} must be a JSON object`); - } - return value; -} - -function assertExactKeys(value, expectedKeys, path) { - const object = assertPlainObject(value, path); - const actual = Object.keys(object).sort(); - const expected = [...expectedKeys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { - fail(`${path} must contain exactly: ${expected.join(", ")}`); - } - return object; -} - -function assertString(value, path, pattern) { - if (typeof value !== "string" || (pattern && !pattern.test(value))) { - fail(`${path} has an invalid string value`); - } - return value; -} - -function assertInteger(value, path, { positive = false } = {}) { - if (!Number.isSafeInteger(value) || (positive && value <= 0)) { - fail(`${path} must be a ${positive ? "positive " : ""}safe integer`); - } - return value; -} - -function assertLiteral(value, expected, path) { - if (value !== expected) { - fail(`${path} must equal ${JSON.stringify(expected)}`); - } - return value; -} - -function sortJson(value) { - if (Array.isArray(value)) { - return value.map(sortJson); - } - if (isPlainObject(value)) { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, sortJson(value[key])]) - ); - } - return value; -} - -function canonicalJson(value) { - return `${JSON.stringify(sortJson(value), null, 2)}\n`; -} - -function sha256(bytes) { - return createHash("sha256").update(bytes).digest("hex"); -} - -function parseCanonicalManifest(rawBytes, path) { - let parsed; - try { - parsed = JSON.parse(rawBytes.toString("utf8")); - } catch (error) { - fail(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); - } - - const canonicalBytes = Buffer.from(canonicalJson(parsed)); - if (!rawBytes.equals(canonicalBytes)) { - fail( - `${path} is not canonical key-sorted two-space JSON with one trailing LF (duplicate keys are also rejected)` - ); - } - - const manifest = assertExactKeys( - parsed, - ["schema", "environment", "source", "release", "artifact", "measurements", "build"], - "manifest" - ); - assertLiteral(manifest.schema, MANIFEST_SCHEMA, "manifest.schema"); - if (manifest.environment !== "prod" && manifest.environment !== "dev") { - fail("manifest.environment must be prod or dev"); - } - - const source = assertExactKeys( - manifest.source, - ["repository", "repositoryId", "ownerId", "ref", "commit"], - "manifest.source" - ); - assertLiteral(source.repository, SOURCE_REPOSITORY, "manifest.source.repository"); - assertLiteral(source.repositoryId, SOURCE_REPOSITORY_ID, "manifest.source.repositoryId"); - assertLiteral(source.ownerId, SOURCE_REPOSITORY_OWNER_ID, "manifest.source.ownerId"); - assertString(source.commit, "manifest.source.commit", /^[0-9a-f]{40}$/); - - const release = assertExactKeys(manifest.release, ["tag"], "manifest.release"); - assertString(release.tag, "manifest.release.tag", /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); - assertLiteral(source.ref, `refs/tags/${release.tag}`, "manifest.source.ref"); - - const artifact = assertExactKeys( - manifest.artifact, - ["name", "mediaType", "sha256", "size"], - "manifest.artifact" - ); - assertLiteral( - artifact.name, - `opensecret-${release.tag}-${manifest.environment}.eif`, - "manifest.artifact.name" - ); - assertLiteral(artifact.mediaType, "application/vnd.aws.nitro.eif", "manifest.artifact.mediaType"); - assertString(artifact.sha256, "manifest.artifact.sha256", /^[0-9a-f]{64}$/); - assertInteger(artifact.size, "manifest.artifact.size", { positive: true }); - - const measurements = assertExactKeys( - manifest.measurements, - ["algorithm", "requiredPcrs", "pcrs"], - "manifest.measurements" - ); - assertLiteral(measurements.algorithm, "sha384", "manifest.measurements.algorithm"); - if ( - !Array.isArray(measurements.requiredPcrs) || - measurements.requiredPcrs.length !== 3 || - measurements.requiredPcrs.some((value, index) => value !== index) - ) { - fail("manifest.measurements.requiredPcrs must equal [0, 1, 2]"); - } - const pcrs = assertExactKeys(measurements.pcrs, ["0", "1", "2"], "manifest.measurements.pcrs"); - for (const pcr of ["0", "1", "2"]) { - const value = assertString(pcrs[pcr], `manifest.measurements.pcrs.${pcr}`, /^[0-9a-f]{96}$/); - if (/^0+$/.test(value)) { - fail(`manifest.measurements.pcrs.${pcr} must not be all zero`); - } - } - - const build = assertExactKeys( - manifest.build, - ["system", "flakeLockSha256", "derivation", "workflowRun"], - "manifest.build" - ); - assertLiteral(build.system, "nix", "manifest.build.system"); - assertString(build.flakeLockSha256, "manifest.build.flakeLockSha256", /^[0-9a-f]{64}$/); - assertLiteral(build.derivation, `eif-${manifest.environment}`, "manifest.build.derivation"); - const workflowRun = assertString( - build.workflowRun, - "manifest.build.workflowRun", - /^https:\/\/github\.com\/OpenSecretCloud\/opensecret\/actions\/runs\/[1-9]\d*\/attempts\/[1-9]\d*$/ - ); - new URL(workflowRun); - - return manifest; -} - -function parseBundle(rawBytes, path, expectedManifestSha256) { - let bundle; - try { - bundle = JSON.parse(rawBytes.toString("utf8")); - } catch (error) { - fail(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); - } - - assertPlainObject(bundle, "bundle"); - assertLiteral(bundle.mediaType, BUNDLE_MEDIA_TYPE, "bundle.mediaType"); - if (!isPlainObject(bundle.messageSignature) || bundle.dsseEnvelope !== undefined) { - fail("bundle must contain a v0.3 messageSignature and no DSSE envelope"); - } - const messageDigest = assertPlainObject( - bundle.messageSignature.messageDigest, - "bundle.messageSignature.messageDigest" - ); - assertLiteral( - messageDigest.algorithm, - "SHA2_256", - "bundle.messageSignature.messageDigest.algorithm" - ); - const encodedDigest = assertString( - messageDigest.digest, - "bundle.messageSignature.messageDigest.digest" - ); - let digest; - try { - digest = Buffer.from(encodedDigest, "base64"); - } catch { - fail("bundle.messageSignature.messageDigest.digest must be base64"); - } - if (digest.length !== 32 || digest.toString("hex") !== expectedManifestSha256) { - fail("bundle message digest does not match the exact manifest bytes"); - } - - const verificationMaterial = assertPlainObject( - bundle.verificationMaterial, - "bundle.verificationMaterial" - ); - if (!isPlainObject(verificationMaterial.certificate)) { - fail("bundle must contain exactly one Fulcio certificate"); - } - if (verificationMaterial.x509CertificateChain !== undefined) { - fail("legacy x509CertificateChain bundles are not accepted"); - } - - const tlogEntries = verificationMaterial.tlogEntries; - if (!Array.isArray(tlogEntries) || tlogEntries.length !== 1) { - fail("bundle must contain exactly one transparency-log entry"); - } - const tlogEntry = assertPlainObject(tlogEntries[0], "bundle.verificationMaterial.tlogEntries[0]"); - const inclusionProof = assertPlainObject( - tlogEntry.inclusionProof, - "bundle.verificationMaterial.tlogEntries[0].inclusionProof" - ); - const checkpoint = assertPlainObject( - inclusionProof.checkpoint, - "bundle.verificationMaterial.tlogEntries[0].inclusionProof.checkpoint" - ); - assertString( - checkpoint.envelope, - "bundle.verificationMaterial.tlogEntries[0].inclusionProof.checkpoint.envelope", - /[\S]/ - ); - - const rawLogIndex = tlogEntry.logIndex; - const encodedLogIndex = - typeof rawLogIndex === "number" && Number.isSafeInteger(rawLogIndex) && rawLogIndex >= 0 - ? String(rawLogIndex) - : assertString(rawLogIndex, "bundle.verificationMaterial.tlogEntries[0].logIndex", /^\d+$/); - const logIndex = BigInt(encodedLogIndex).toString(); - const logId = assertPlainObject( - tlogEntry.logId, - "bundle.verificationMaterial.tlogEntries[0].logId" - ); - const encodedLogIdKey = assertString( - logId.keyId, - "bundle.verificationMaterial.tlogEntries[0].logId.keyId" - ); - const logIdBytes = Buffer.from(encodedLogIdKey, "base64"); - if (logIdBytes.length !== 32) { - fail("bundle.verificationMaterial.tlogEntries[0].logId.keyId must encode 32 bytes"); - } - - return { - bundle, - transparencyLog: { logIndex, logId: logIdBytes.toString("hex") } - }; -} - -function parseArgs(argv) { - const manifests = []; - const bundles = []; - const outputs = []; - let cosign = process.env.COSIGN_BIN || "cosign"; - - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - const value = argv[index + 1]; - if (argument === "--help" || argument === "-h") { - process.stdout.write(usage()); - process.exit(0); - } - if (!value || value.startsWith("--")) { - fail(`missing value for ${argument}`); - } - if (argument === "--manifest") { - manifests.push(resolve(value)); - } else if (argument === "--bundle") { - bundles.push(resolve(value)); - } else if (argument === "--output") { - outputs.push(resolve(value)); - } else if (argument === "--cosign") { - cosign = resolve(value); - } else { - fail(`unknown argument: ${argument}`); - } - index += 1; - } - - if (manifests.length === 0 || manifests.length !== bundles.length) { - fail("supply the same non-zero number of --manifest and --bundle arguments"); - } - - return { manifests, bundles, outputs: outputs.length > 0 ? outputs : defaultOutputs, cosign }; -} - -function parseVersion(text) { - const match = text.match(/v?(\d+)\.(\d+)\.(\d+)/); - if (!match) { - fail(`could not parse Cosign version from: ${text.trim()}`); - } - return match.slice(1).map(Number); -} - -function requireCosign(cosign) { - const result = spawnSync(cosign, ["version", "--json"], { encoding: "utf8" }); - if (result.error) { - fail(`failed to execute Cosign at ${cosign}: ${result.error.message}`); - } - if (result.status !== 0) { - fail(`Cosign version check failed: ${result.stderr || result.stdout}`); - } - const version = parseVersion(result.stdout || result.stderr); - if (version.some((part, index) => part !== REQUIRED_COSIGN_VERSION[index])) { - fail( - `Cosign ${version.join(".")} is not supported; exactly ${REQUIRED_COSIGN_VERSION.join(".")} is required` - ); - } -} - -function expectedSignerIdentity(manifest) { - return `${SOURCE_REPOSITORY_URI}/${WORKFLOW_PATH}@${manifest.source.ref}`; -} - -function verifyWithCosign(cosign, manifestPath, bundlePath, manifest) { - const identity = expectedSignerIdentity(manifest); - const arguments_ = [ - "verify-blob", - "--bundle", - bundlePath, - "--certificate-identity", - identity, - "--certificate-oidc-issuer", - OIDC_ISSUER, - "--certificate-github-workflow-name", - WORKFLOW_NAME, - "--certificate-github-workflow-repository", - SOURCE_REPOSITORY, - "--certificate-github-workflow-ref", - manifest.source.ref, - "--certificate-github-workflow-sha", - manifest.source.commit, - "--certificate-github-workflow-trigger", - WORKFLOW_TRIGGER, - manifestPath - ]; - const result = spawnSync(cosign, arguments_, { encoding: "utf8" }); - if (result.error) { - fail(`failed to execute Cosign: ${result.error.message}`); - } - if (result.status !== 0) { - fail(`Cosign rejected ${manifestPath}: ${result.stderr || result.stdout}`); - } -} - -function escapeRegex(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function expectedGenericFulcioOids(manifest) { - return { - "1.3.6.1.4.1.57264.1.8": OIDC_ISSUER, - "1.3.6.1.4.1.57264.1.9": expectedSignerIdentity(manifest), - "1.3.6.1.4.1.57264.1.10": manifest.source.commit, - "1.3.6.1.4.1.57264.1.11": "github-hosted", - "1.3.6.1.4.1.57264.1.12": SOURCE_REPOSITORY_URI, - "1.3.6.1.4.1.57264.1.13": manifest.source.commit, - "1.3.6.1.4.1.57264.1.14": manifest.source.ref, - "1.3.6.1.4.1.57264.1.15": String(SOURCE_REPOSITORY_ID), - "1.3.6.1.4.1.57264.1.16": SOURCE_REPOSITORY_OWNER_URI, - "1.3.6.1.4.1.57264.1.17": String(SOURCE_REPOSITORY_OWNER_ID), - "1.3.6.1.4.1.57264.1.18": expectedSignerIdentity(manifest), - "1.3.6.1.4.1.57264.1.19": manifest.source.commit, - "1.3.6.1.4.1.57264.1.20": WORKFLOW_TRIGGER, - "1.3.6.1.4.1.57264.1.21": manifest.build.workflowRun, - "1.3.6.1.4.1.57264.1.22": "public", - "1.3.6.1.4.1.57264.1.23": WORKFLOW_ENVIRONMENT, - "1.3.6.1.4.1.57264.1.24": `repo:${SOURCE_REPOSITORY}:environment:${WORKFLOW_ENVIRONMENT}` - }; -} - -export function decodeDerUtf8String(value, context = "Fulcio extension") { - if (!(value instanceof Uint8Array)) { - fail(`${context} must be a byte string`); - } - - const bytes = Buffer.from(value); - if (bytes.length < 2 || bytes[0] !== 0x0c) { - fail(`${context} must be a DER UTF8String`); - } - - const firstLengthByte = bytes[1]; - let headerLength; - let contentLength; - if (firstLengthByte < 0x80) { - headerLength = 2; - contentLength = firstLengthByte; - } else { - const lengthByteCount = firstLengthByte & 0x7f; - if (lengthByteCount === 0) { - fail(`${context} uses an indefinite DER length`); - } - if (lengthByteCount > 4 || bytes.length < 2 + lengthByteCount) { - fail(`${context} has an invalid DER length`); - } - if (bytes[2] === 0) { - fail(`${context} has a non-minimal DER length`); - } - - contentLength = 0; - for (let index = 0; index < lengthByteCount; index += 1) { - contentLength = contentLength * 256 + bytes[2 + index]; - } - if (contentLength < 0x80) { - fail(`${context} has a non-minimal DER length`); - } - headerLength = 2 + lengthByteCount; - } - - if (headerLength + contentLength !== bytes.length) { - fail(`${context} DER length does not match its value`); - } - - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(headerLength)); - } catch { - fail(`${context} is not valid UTF-8`); - } -} - -function oidToString(oid) { - const components = oid?.id; - if ( - !Array.isArray(components) || - components.length === 0 || - components.some((component) => !Number.isSafeInteger(component) || component < 0) - ) { - return undefined; - } - return components.join("."); -} - -export function verifyGenericFulcioOids(signer, expectedOids) { - const signerOids = signer?.identity?.oids; - if (!Array.isArray(signerOids)) { - fail("verified Fulcio signer did not expose certificate OIDs"); - } - - const expected = new Map(Object.entries(expectedOids)); - const observed = new Map(); - for (const signerOid of signerOids) { - const oid = oidToString(signerOid?.oid); - if (!oid || !expected.has(oid)) { - continue; - } - if (observed.has(oid)) { - fail(`verified Fulcio signer contains duplicate OID ${oid}`); - } - observed.set(oid, decodeDerUtf8String(signerOid.value, `Fulcio OID ${oid}`)); - } - - for (const [oid, expectedValue] of expected) { - if (!observed.has(oid)) { - fail(`verified Fulcio signer is missing OID ${oid}`); - } - const observedValue = observed.get(oid); - if (observedValue !== expectedValue) { - fail( - `verified Fulcio signer has unexpected OID ${oid}: expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(observedValue)}` - ); - } - } -} - -async function verifyWithSigstoreJs(bundle, manifestBytes, manifest) { - const identity = expectedSignerIdentity(manifest); - const signer = await verify(bundle, manifestBytes, { - certificateIssuer: OIDC_ISSUER, - certificateIdentityURI: `^${escapeRegex(identity)}$`, - // sigstore-js compares these deprecated extensions as raw strings. Fulcio's - // provider-generic extensions are DER UTF8Strings, so validate those below. - certificateOIDs: { - "1.3.6.1.4.1.57264.1.2": WORKFLOW_TRIGGER, - "1.3.6.1.4.1.57264.1.3": manifest.source.commit, - "1.3.6.1.4.1.57264.1.4": WORKFLOW_NAME, - "1.3.6.1.4.1.57264.1.5": SOURCE_REPOSITORY, - "1.3.6.1.4.1.57264.1.6": manifest.source.ref - }, - tlogThreshold: 1, - ctLogThreshold: 1 - }); - verifyGenericFulcioOids(signer, expectedGenericFulcioOids(manifest)); -} - -function validateNoDuplicateReleases(releases) { - const unique = { - release: new Set(), - manifest: new Set() - }; - - for (const release of releases) { - const manifest = release.manifest; - const releaseKey = `${manifest.environment}:${manifest.release.tag}`; - for (const [kind, key] of [ - ["release", releaseKey], - ["manifest", release.manifestSha256] - ]) { - if (unique[kind].has(key)) { - fail(`duplicate ${kind} entry in trusted-release inputs: ${key}`); - } - unique[kind].add(key); - } - } -} - -function compareReleaseTags(left, right) { - const leftParts = left.slice(1).split(".").map(BigInt); - const rightParts = right.slice(1).split(".").map(BigInt); - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] > rightParts[index]) return 1; - if (leftParts[index] < rightParts[index]) return -1; - } - return 0; -} - -function writeSnapshotAtomically(path, contents) { - const temporaryPath = `${path}.tmp-${process.pid}`; - writeFileSync(temporaryPath, contents, { encoding: "utf8", mode: 0o644 }); - renameSync(temporaryPath, path); -} - -function assertSupportedNodeVersion() { - const [major, minor] = process.versions.node.split(".").map(Number); - if (!((major === 24 && minor >= 15) || major >= 26)) { - fail("trusted-release updates require Node 24.15 or newer supported by sigstore-js 5"); - } -} - -async function main() { - assertSupportedNodeVersion(); - const { manifests, bundles, outputs, cosign } = parseArgs(process.argv.slice(2)); - requireCosign(cosign); - - const releases = []; - for (let index = 0; index < manifests.length; index += 1) { - const manifestPath = manifests[index]; - const bundlePath = bundles[index]; - const manifestBytes = readFileSync(manifestPath); - const bundleBytes = readFileSync(bundlePath); - const manifestSha256 = sha256(manifestBytes); - const manifest = parseCanonicalManifest(manifestBytes, manifestPath); - const { bundle, transparencyLog } = parseBundle(bundleBytes, bundlePath, manifestSha256); - - verifyWithCosign(cosign, manifestPath, bundlePath, manifest); - await verifyWithSigstoreJs(bundle, manifestBytes, manifest); - - releases.push({ - manifestSha256, - bundleSha256: sha256(bundleBytes), - signer: { - oidcIssuer: OIDC_ISSUER, - identity: expectedSignerIdentity(manifest) - }, - transparencyLog, - manifest - }); - } - - releases.sort((left, right) => { - const environmentOrder = - left.manifest.environment < right.manifest.environment - ? -1 - : left.manifest.environment > right.manifest.environment - ? 1 - : 0; - return ( - environmentOrder || compareReleaseTags(left.manifest.release.tag, right.manifest.release.tag) - ); - }); - validateNoDuplicateReleases(releases); - - const snapshotWithoutId = { - schema: SNAPSHOT_SCHEMA, - policy: SNAPSHOT_POLICY, - releases - }; - const snapshot = { - ...snapshotWithoutId, - snapshotId: sha256(Buffer.from(canonicalJson(snapshotWithoutId))) - }; - const output = canonicalJson(snapshot); - - for (const outputPath of outputs) { - writeSnapshotAtomically(outputPath, output); - process.stdout.write(`Wrote ${outputPath}\n`); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; - }); -} diff --git a/sdk/scripts/update-trusted-enclave-releases.test.mjs b/sdk/scripts/update-trusted-enclave-releases.test.mjs deleted file mode 100644 index bd8fdb85b..000000000 --- a/sdk/scripts/update-trusted-enclave-releases.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - decodeDerUtf8String, - verifyGenericFulcioOids -} from "./update-trusted-enclave-releases.mjs"; - -function encodeDerUtf8String(value) { - const bytes = Buffer.from(value, "utf8"); - if (bytes.length < 0x80) { - return Buffer.concat([Buffer.from([0x0c, bytes.length]), bytes]); - } - - const lengthBytes = []; - let length = bytes.length; - while (length > 0) { - lengthBytes.unshift(length & 0xff); - length = Math.floor(length / 256); - } - return Buffer.concat([Buffer.from([0x0c, 0x80 | lengthBytes.length, ...lengthBytes]), bytes]); -} - -function signerOid(oid, value) { - return { - oid: { id: oid.split(".").map(Number) }, - value: encodeDerUtf8String(value) - }; -} - -test("decodes canonical short and long DER UTF8String values", () => { - assert.equal(decodeDerUtf8String(encodeDerUtf8String("github-hosted")), "github-hosted"); - const longValue = "a".repeat(256); - assert.equal(decodeDerUtf8String(encodeDerUtf8String(longValue)), longValue); -}); - -test("rejects malformed DER UTF8String values", () => { - for (const malformed of [ - Buffer.from([0x16, 0x01, 0x61]), - Buffer.from([0x0c, 0x80, 0x00, 0x00]), - Buffer.from([0x0c, 0x81, 0x01, 0x61]), - Buffer.from([0x0c, 0x82, 0x00, 0x80, ...Buffer.alloc(0x80)]), - Buffer.from([0x0c, 0x02, 0x61]), - Buffer.from([0x0c, 0x01, 0x61, 0x62]), - Buffer.from([0x0c, 0x01, 0xff]) - ]) { - assert.throws(() => decodeDerUtf8String(malformed)); - } -}); - -test("requires every generic Fulcio claim to match exactly", () => { - const expected = { - "1.3.6.1.4.1.57264.1.9": "workflow identity", - "1.3.6.1.4.1.57264.1.21": "run invocation" - }; - const signer = { - identity: { - oids: Object.entries(expected).map(([oid, value]) => signerOid(oid, value)) - } - }; - - assert.doesNotThrow(() => verifyGenericFulcioOids(signer, expected)); - assert.throws( - () => - verifyGenericFulcioOids( - { - identity: { - oids: [ - signerOid("1.3.6.1.4.1.57264.1.9", "workflow identity"), - signerOid("1.3.6.1.4.1.57264.1.9", "workflow identity"), - signerOid("1.3.6.1.4.1.57264.1.21", "run invocation") - ] - } - }, - expected - ), - /duplicate OID/ - ); - assert.throws( - () => - verifyGenericFulcioOids( - { - identity: { - oids: [signerOid("1.3.6.1.4.1.57264.1.9", "workflow identity")] - } - }, - expected - ), - /missing OID/ - ); - assert.throws( - () => - verifyGenericFulcioOids( - { - identity: { - oids: [ - signerOid("1.3.6.1.4.1.57264.1.9", "wrong identity"), - signerOid("1.3.6.1.4.1.57264.1.21", "run invocation") - ] - } - }, - expected - ), - /unexpected OID/ - ); -}); 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 9f3c58201..f707e2c48 100644 --- a/sdk/src/lib/attestation.ts +++ b/sdk/src/lib/attestation.ts @@ -4,10 +4,11 @@ import * as cbor from "cbor2"; import { z } from "zod"; import { fetchAttestationDocument, getApiUrl } from "./api"; import { - assertTrustedReleaseSnapshotIntegrity, - requireTrustedPcrs, + requireTrustedPcrsAgainstSnapshot, resolveAttestationEnvironment, - type AttestationEnvironment + resolveTrustedPcrPolicy, + type AttestationEnvironment, + type TrustedEnclaveReleaseSnapshot } from "./pcr"; import awsRootCertDer from "../assets/aws_root.der"; @@ -274,7 +275,8 @@ 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, expectedEnvironment?: AttestationEnvironment @@ -301,9 +303,7 @@ export async function verifyAttestation( if (!apiUrl) { throw new Error("Attestation API URL is not configured."); } - const environment = resolveAttestationEnvironment(apiUrl, expectedEnvironment); - await assertTrustedReleaseSnapshotIntegrity(); - requireTrustedPcrs(verifiedDocument.pcrs, environment); + resolveAttestationEnvironment(apiUrl, expectedEnvironment); return verifiedDocument; } catch (error) { if (error instanceof Error) { @@ -315,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 19ebf5a82..fb0c10411 100644 --- a/sdk/src/lib/attestationForView.ts +++ b/sdk/src/lib/attestationForView.ts @@ -3,12 +3,12 @@ import { type AttestationDocument } from "./attestation"; import awsRootCertDer from "../assets/aws_root.der"; import { X509Certificate } from "@peculiar/x509"; import { - assertTrustedReleaseSnapshotIntegrity, validatePcrsAgainstSnapshot, getTrustedReleaseSnapshotId, type Pcr0ValidationResult, type PcrConfig } from "./pcr"; +import { refreshAttestationPolicy } from "./attestationTuf"; export const AWS_ROOT_CERT_DER = awsRootCertDer; @@ -56,7 +56,6 @@ export async function parseAttestationForView( cabundle: Uint8Array[], pcrConfig?: PcrConfig ): Promise { - await assertTrustedReleaseSnapshotIntegrity(); // Add logging to see what we're getting console.log("Raw timestamp:", document.timestamp); console.log("Date object:", new Date(document.timestamp)); @@ -70,7 +69,11 @@ export async function parseAttestationForView( .filter((pcr) => !pcr.value.match(/^0+$/)); const validatedPcrs = pcrConfig?.environment - ? validatePcrsAgainstSnapshot(document.pcrs, 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", diff --git a/sdk/src/lib/attestationTuf.ts b/sdk/src/lib/attestationTuf.ts new file mode 100644 index 000000000..2257aaaf2 --- /dev/null +++ b/sdk/src/lib/attestationTuf.ts @@ -0,0 +1,3672 @@ +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"; + +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 BUILDER_POLICY_SCHEMA = "https://attestations.trymaple.ai/schemas/sigstore-builder-policy/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, + builderPolicyTarget: TargetReferenceSchema, + 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(); + +const BuilderIdentitySchema = z + .object({ + certificateIdentityRegexp: z.string().min(2).max(2048), + certificateOidcIssuer: z + .string() + .url() + .refine(isExactHttpsUrl, "OIDC issuer must be an exact HTTPS URL"), + workflowRepository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), + workflowName: z.string().min(1).max(512), + workflowTrigger: z.string().min(1).max(128) + }) + .strict() + .refine( + (builder) => + builder.certificateIdentityRegexp.startsWith("^") && + builder.certificateIdentityRegexp.endsWith("$") && + isValidRegexp(builder.certificateIdentityRegexp), + "certificate identity policy must be anchored" + ); + +const BuilderPolicySchema = z + .object({ + schema: z.literal(BUILDER_POLICY_SCHEMA), + builders: z.record( + z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/), + BuilderIdentitySchema + ) + }) + .strict() + .superRefine((policy, context) => { + const ids = Object.keys(policy.builders); + if (ids.length === 0 || ids.length > 32) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["builders"], + message: "builder policy must contain between 1 and 32 builders" + }); + } + }); + +export type NitroReleaseManifest = z.infer; +export type AttestationBuilderPolicy = z.infer; +export type AttestationBuilderIdentity = z.infer & { id: string }; + +export type SigstoreEvidence = { + bundleTarget: string; + bundleSha256: string; + trustedRootTarget: string; + trustedRootSha256: string; + builderPolicyTarget: string; + builderPolicySha256: string; + builder: AttestationBuilderIdentity; +}; + +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 isValidRegexp(value: string): boolean { + try { + new RegExp(value); + return true; + } catch { + return false; + } +} + +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]; +} + +function sourceUriMatchesRepository(sourceUri: string, workflowRepository: string): boolean { + const pathname = new URL(sourceUri).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); + return pathname === workflowRepository; +} + +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; +} + +function assertTargetReference( + targets: TargetsEnvelope, + path: string, + expectedSha256: string, + maxBytes: number, + description: string +): TargetFile { + const descriptor = targetDescriptor(targets, path); + if (descriptor.length > maxBytes) { + throw new AttestationTrustError( + "TRUST_SIZE_LIMIT", + `${description} exceeds the ${maxBytes}-byte limit.` + ); + } + if (descriptor.hashes.sha256 !== expectedSha256) { + throw new AttestationTrustError( + "TUF_TARGET_INTEGRITY", + `${description} digest does not match its channel reference.` + ); + } + return descriptor; +} + +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.builderPolicyTarget.path !== "policy/builders.json") { + throw new AttestationTrustError( + "POLICY_INVALID", + "builderPolicyTarget.path must be policy/builders.json." + ); + } + if (channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json") { + throw new AttestationTrustError( + "POLICY_INVALID", + "sigstoreTrustedRootTarget.path must be sigstore/trusted_root.json." + ); + } + + const builderPolicyBytes = await verifyCachedTarget( + raw, + targets, + channel.builderPolicyTarget.path, + channel.builderPolicyTarget.sha256, + MAX_POLICY_TARGET_BYTES, + "builder policy" + ); + const builderPolicy = parseJson(builderPolicyBytes, BuilderPolicySchema, "builder policy"); + const buildersById = new Map( + Object.entries(builderPolicy.builders).map(([id, builder]) => [id, { id, ...builder }]) + ); + const expectedCachedTargets = new Set([channelPath, channel.builderPolicyTarget.path]); + + assertTargetReference( + targets, + channel.sigstoreTrustedRootTarget.path, + channel.sigstoreTrustedRootTarget.sha256, + MAX_TRUST_ROOT_BYTES, + "Sigstore trusted root" + ); + + 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." + ); + } + const builder = buildersById.get(manifest.build.builderId); + if (!builder) { + throw new AttestationTrustError( + "POLICY_INVALID", + `Release manifest references unknown builder ${manifest.build.builderId}.` + ); + } + if (!sourceUriMatchesRepository(manifest.source.uri, builder.workflowRepository)) { + throw new AttestationTrustError( + "POLICY_INVALID", + "Release manifest source URI does not match its authenticated builder repository." + ); + } + assertTargetReference( + targets, + active.bundleTarget, + active.bundleSha256, + MAX_BUNDLE_BYTES, + "Sigstore bundle" + ); + 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, + builderPolicyTarget: channel.builderPolicyTarget.path, + builderPolicySha256: channel.builderPolicyTarget.sha256, + builder + } + }); + } + + 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.builderPolicyTarget.path !== "policy/builders.json" || + channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json" + ) { + throw new AttestationTrustError( + "POLICY_INVALID", + "Channel policy or 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.builderPolicyTarget.path, + channel.builderPolicyTarget.sha256, + MAX_POLICY_TARGET_BYTES, + "builder policy", + true + ], + [ + channel.sigstoreTrustedRootTarget.path, + channel.sigstoreTrustedRootTarget.sha256, + MAX_TRUST_ROOT_BYTES, + "Sigstore trusted root", + false + ] + ]; + 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", false] + ); + } + + 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); + // 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)); + } + const memory = this.memory.get(environment); + if (memory) verified.push(await verifyRawGeneration(memory.raw, bootstrap, now, 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); + storedGeneration = storedGeneration + ? newestGeneration(storedGeneration, verified) + : verified; + } + const memory = this.memory.get(channel); + const memoryGeneration = memory + ? await verifyRawGeneration(memory.raw, bootstrap, now, 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); + 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); + } + + 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)); + for (const stored of this.readStorage(channel, true)) { + existing.push(await verifyRawGeneration(stored.raw, bootstrap, now, 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); + + 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) + ); + } + } + 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); + 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 Test-only client factory; not exported from the package entry point. */ +export function createAttestationTufClientForTesting( + options: Required> & { + storage?: Storage | null; + } +): AttestationTufClient { + return new AttestationTufClient(options); +} + +/** @internal Exercises the official embedded-root release sentinel in tests. */ +export function assertOfficialEmbeddedBootstrapForTesting(bootstrap: unknown): void { + assertOfficialEmbeddedBootstrap(bootstrap); +} diff --git a/sdk/src/lib/getAttestation.ts b/sdk/src/lib/getAttestation.ts index a4ce3f6fa..66e5ebec2 100644 --- a/sdk/src/lib/getAttestation.ts +++ b/sdk/src/lib/getAttestation.ts @@ -1,17 +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 { - assertTrustedReleaseSnapshotIntegrity, - requireTrustedPcrs, + requireTrustedPcrsAgainstSnapshot, resolveAttestationEnvironment, + resolveTrustedPcrPolicy, serializePcrConfig, snapshotPcrConfig, type AttestationEnvironment, - type PcrConfig + type PcrConfig, + type TrustedEnclaveReleaseSnapshot } from "./pcr"; export interface Attestation { @@ -40,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; - requireTrustedPcrs: typeof requireTrustedPcrs; + verifyAttestation: typeof verifyAttestationDocument; + resolveTrustedPcrPolicy: typeof resolveTrustedPcrPolicy; + requireTrustedPcrsAgainstSnapshot: typeof requireTrustedPcrsAgainstSnapshot; keyExchange: typeof keyExchange; generateNaclKeyPair: () => NaclKeyPair; decryptSessionKey: ( @@ -80,8 +82,9 @@ function decryptSessionKey( } const defaultDependencies: GetAttestationDependencies = { - verifyAttestation, - requireTrustedPcrs, + verifyAttestation: verifyAttestationDocument, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot, keyExchange, generateNaclKeyPair, decryptSessionKey, @@ -297,6 +300,14 @@ 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( @@ -314,8 +325,11 @@ export async function getAttestationWithDependencies( verifiedPcr0 = "local-development"; console.warn("LOCAL DEVELOPMENT: PCR0 verification is bypassed for exact HTTP loopback."); } else { - await assertTrustedReleaseSnapshotIntegrity(); - dependencies.requireTrustedPcrs(document.pcrs, expectedEnvironment!); + await dependencies.requireTrustedPcrsAgainstSnapshot( + document.pcrs, + expectedEnvironment!, + trustedPolicy! + ); const pcr0 = document.pcrs.get(0); if (!pcr0 || pcr0.length !== 48) { throw new Error("Attestation document must contain a 48-byte PCR0 value."); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index 3c7f5593a..f8806602c 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -153,6 +153,14 @@ export { type TrustedEnclaveRelease, type TrustedEnclaveReleaseSnapshot } from "./pcr"; +export { + ATTESTATION_TUF_BASE_URL, + AttestationTrustError, + type AttestationBuilderIdentity, + 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/pcr.ts b/sdk/src/lib/pcr.ts index 3b73d8f53..ab9f293a3 100644 --- a/sdk/src/lib/pcr.ts +++ b/sdk/src/lib/pcr.ts @@ -1,311 +1,72 @@ import { z } from "zod"; -import trustedReleaseSnapshotJson from "./trusted-enclave-releases.generated.json"; +import { + assertAttestationPolicyCurrent, + getCachedAttestationPolicy, + refreshAttestationPolicy, + type AttestationChannel, + type TrustedTufRelease, + type VerifiedAttestationPolicy +} from "./attestationTuf"; -const SNAPSHOT_SCHEMA = "https://opensecret.cloud/sdk/trusted-enclave-releases/v1"; -const MANIFEST_SCHEMA = "https://opensecret.cloud/attestations/nitro-eif-release/v1"; -const SOURCE_REPOSITORY = "OpenSecretCloud/opensecret"; -const EIF_MEDIA_TYPE = "application/vnd.aws.nitro.eif"; -const PCR_HEX_PATTERN = /^[0-9a-f]{96}$/; -const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; -const COMMIT_HEX_PATTERN = /^[0-9a-f]{40}$/; -const RELEASE_TAG_PATTERN = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; -const WORKFLOW_PATH = ".github/workflows/release-nitro-eif.yml"; -const OIDC_ISSUER = "https://token.actions.githubusercontent.com"; const LOCAL_DEVELOPMENT_API_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); export const AttestationEnvironmentSchema = z.enum(["prod", "dev"]); -export type AttestationEnvironment = z.infer; - -const PcrMeasurementsSchema = 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((value) => !/^0+$/.test(value)), - "1": z - .string() - .regex(PCR_HEX_PATTERN) - .refine((value) => !/^0+$/.test(value)), - "2": z - .string() - .regex(PCR_HEX_PATTERN) - .refine((value) => !/^0+$/.test(value)) - }) - .strict() - }) - .strict(); - -const ReleaseManifestSchema = z - .object({ - schema: z.literal(MANIFEST_SCHEMA), - environment: AttestationEnvironmentSchema, - source: z - .object({ - repository: z.literal(SOURCE_REPOSITORY), - repositoryId: z.literal(921901924), - ownerId: z.literal(185423582), - ref: z.string().startsWith("refs/tags/"), - commit: z.string().regex(COMMIT_HEX_PATTERN) - }) - .strict(), - release: z - .object({ - tag: z.string().regex(RELEASE_TAG_PATTERN) - }) - .strict(), - artifact: z - .object({ - name: z.string().min(1), - mediaType: z.literal(EIF_MEDIA_TYPE), - sha256: z.string().regex(SHA256_HEX_PATTERN), - size: z.number().safe().int().positive() - }) - .strict(), - measurements: PcrMeasurementsSchema, - build: z - .object({ - system: z.literal("nix"), - flakeLockSha256: z.string().regex(SHA256_HEX_PATTERN), - derivation: z.enum(["eif-prod", "eif-dev"]), - workflowRun: z - .string() - .regex( - /^https:\/\/github\.com\/OpenSecretCloud\/opensecret\/actions\/runs\/[1-9]\d*\/attempts\/[1-9]\d*$/ - ) - }) - .strict() - }) - .strict() - .superRefine((manifest, context) => { - if (manifest.source.ref !== `refs/tags/${manifest.release.tag}`) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["source", "ref"], - message: "source ref must be the exact release tag" - }); - } - - if ( - manifest.artifact.name !== `opensecret-${manifest.release.tag}-${manifest.environment}.eif` - ) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["artifact", "name"], - message: "artifact name must match the release tag and environment" - }); - } - - if (manifest.build.derivation !== `eif-${manifest.environment}`) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["build", "derivation"], - message: "build derivation must match the release environment" - }); - } - }); - -const TrustedReleaseSchema = z - .object({ - manifestSha256: z.string().regex(SHA256_HEX_PATTERN), - bundleSha256: z.string().regex(SHA256_HEX_PATTERN), - signer: z - .object({ - oidcIssuer: z.literal(OIDC_ISSUER), - identity: z.string().url() - }) - .strict(), - transparencyLog: z - .object({ - logIndex: z.string().regex(/^(0|[1-9]\d*)$/), - logId: z.string().regex(SHA256_HEX_PATTERN) - }) - .strict(), - manifest: ReleaseManifestSchema - }) - .strict() - .superRefine((release, context) => { - const expectedIdentity = `https://github.com/${SOURCE_REPOSITORY}/${WORKFLOW_PATH}@${release.manifest.source.ref}`; - if (release.signer.identity !== expectedIdentity) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["signer", "identity"], - message: "signer identity must match the exact release workflow and manifest tag" - }); - } - }); - -const TrustedReleaseSnapshotSchema = z - .object({ - schema: z.literal(SNAPSHOT_SCHEMA), - snapshotId: z.string().regex(SHA256_HEX_PATTERN), - policy: z - .object({ - oidcIssuer: z.literal(OIDC_ISSUER), - sourceRepository: z.literal(SOURCE_REPOSITORY), - sourceRepositoryId: z.literal(921901924), - sourceRepositoryOwnerId: z.literal(185423582), - workflow: z - .object({ - path: z.literal(WORKFLOW_PATH), - name: z.literal("Nitro EIF Release"), - trigger: z.literal("workflow_dispatch"), - environment: z.literal("production-release") - }) - .strict() - }) - .strict(), - releases: z.array(TrustedReleaseSchema) - }) - .strict() - .superRefine((snapshot, context) => { - const releaseKeys = new Set(); - const manifestDigests = new Set(); - snapshot.releases.forEach((release, index) => { - const releaseKey = `${release.manifest.environment}:${release.manifest.release.tag}`; - if (releaseKeys.has(releaseKey)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["releases", index], - message: "duplicate release environment and tag" - }); - } - releaseKeys.add(releaseKey); - - if (manifestDigests.has(release.manifestSha256)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["releases", index, "manifestSha256"], - message: "duplicate release manifest digest" - }); - } - manifestDigests.add(release.manifestSha256); - }); - }); - -export type TrustedEnclaveRelease = z.infer; -export type TrustedEnclaveReleaseSnapshot = z.infer; - -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; -} - -const TRUSTED_RELEASE_SNAPSHOT = deepFreeze( - TrustedReleaseSnapshotSchema.parse(trustedReleaseSnapshotJson) -); -let snapshotIntegrityPromise: Promise | undefined; - -function sortJson(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(sortJson); - } - if (value !== null && typeof value === "object") { - const object = value as Record; - return Object.fromEntries( - Object.keys(object) - .sort() - .map((key) => [key, sortJson(object[key])]) - ); - } - return value; -} - -async function sha256CanonicalJson(value: unknown): Promise { - const canonicalBytes = new TextEncoder().encode(`${JSON.stringify(sortJson(value), null, 2)}\n`); - const digest = await crypto.subtle.digest("SHA-256", canonicalBytes); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -export function assertTrustedReleaseSnapshotIntegrity(): Promise { - snapshotIntegrityPromise ??= (async () => { - const snapshotPayload = { - schema: TRUSTED_RELEASE_SNAPSHOT.schema, - policy: TRUSTED_RELEASE_SNAPSHOT.policy, - releases: TRUSTED_RELEASE_SNAPSHOT.releases - }; - const actualSnapshotId = await sha256CanonicalJson(snapshotPayload); - if (actualSnapshotId !== TRUSTED_RELEASE_SNAPSHOT.snapshotId) { - throw new Error("Embedded trusted-release snapshot ID is invalid."); - } - - for (const release of TRUSTED_RELEASE_SNAPSHOT.releases) { - const actualManifestSha256 = await sha256CanonicalJson(release.manifest); - if (actualManifestSha256 !== release.manifestSha256) { - throw new Error( - `Embedded trusted-release manifest digest is invalid for ${release.manifest.release.tag}.` - ); - } - } - })(); - return snapshotIntegrityPromise; -} +export type AttestationEnvironment = AttestationChannel; +export type TrustedEnclaveRelease = TrustedTufRelease; +export type TrustedEnclaveReleaseSnapshot = VerifiedAttestationPolicy; /** - * Attestation policy configuration. - * - * Non-loopback deployments whose origin is not one of the SDK's exact official - * origins must select an environment explicitly. Raw PCR allowlists and remote - * PCR-history URLs are intentionally no longer supported. + * Attestation policy configuration. Official clients retrieve current policy + * from https://attestations.trymaple.ai/tuf. Raw allowlists and GitHub history + * URLs are not trust inputs. */ export type PcrConfig = { environment?: AttestationEnvironment; - /** @deprecated Raw PCR overrides are no longer an authorization mechanism. */ + /** @deprecated Raw PCR overrides are not an authorization mechanism. */ pcr0Values?: never; - /** @deprecated Raw PCR overrides are no longer an authorization mechanism. */ + /** @deprecated Raw PCR overrides are not an authorization mechanism. */ pcr0DevValues?: never; - /** @deprecated Runtime PCR-history fetching has been removed. */ + /** @deprecated Attestation policy has one fixed, authenticated TUF origin. */ remoteAttestation?: never; - /** @deprecated Runtime PCR-history fetching has been removed. */ + /** @deprecated Attestation policy has one fixed, authenticated TUF origin. */ remoteAttestationUrls?: never; }; -/** Return a detached, immutable copy suitable for an attestation session policy. */ export function snapshotPcrConfig(config?: PcrConfig): PcrConfig { return Object.freeze({ environment: config?.environment }); } -/** Canonical policy fingerprint input used to scope cached attestation sessions. */ export function serializePcrConfig(config?: PcrConfig): string { const snapshot = snapshotPcrConfig(config); return JSON.stringify({ - version: "sigstore-trusted-release-v1", - environment: snapshot.environment ?? null, - snapshotId: TRUSTED_RELEASE_SNAPSHOT.snapshotId + version: "attestation-tuf-v1", + environment: snapshot.environment ?? null }); } export type Pcr0ValidationResult = { - /** Whether PCR0, PCR1, and PCR2 match one authenticated release as a tuple. */ isMatch: boolean; - /** Human-readable description of the validation result. */ text: string; - /** Environment selected by caller policy. */ environment?: AttestationEnvironment; releaseTag?: string; + releaseVersion?: string; sourceCommit?: string; sourceRef?: string; artifactSha256?: string; manifestSha256?: string; bundleSha256?: string; snapshotId: string; + channelSequence?: number; + builderId?: string; + /** Authenticated policy that the promotion pipeline applies to the Sigstore certificate. */ + signerIdentityPolicy?: string; + /** @deprecated Browser runtime does not observe or verify a Sigstore signer identity. */ signerIdentity?: string; - transparencyLog?: { - logIndex: string; - logId: string; - }; - /** - * Retained for source compatibility. Sigstore v0.3 verification does not - * expose Rekor integratedTime as a trusted timestamp. - */ + oidcIssuer?: string; + sigstoreTrustedRootSha256?: string; + /** Browser runtime does not interpret transparency evidence from the bundle. */ + transparencyLog?: { logIndex: string; logId: string }; + /** @deprecated Sigstore timestamps are verified by the promotion pipeline. */ verifiedAt?: string; }; @@ -349,78 +110,99 @@ export function resolveAttestationEnvironment( } 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 environment = officialEnvironment ?? explicitEnvironment; if (!environment) { throw new Error( `Attestation environment must be configured explicitly for non-official origin ${origin}.` ); } - return environment; } -export function getTrustedReleaseSnapshotId(): string { - return TRUSTED_RELEASE_SNAPSHOT.snapshotId; +/** @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" + ); } -export function getTrustedReleaseSnapshot(): TrustedEnclaveReleaseSnapshot { - return TRUSTED_RELEASE_SNAPSHOT; +/** @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; } function pcrBytesToHex(value: Uint8Array | undefined): string | null { - if (!value || value.length !== 48) { - return null; - } - + if (!value || value.length !== 48) return null; return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); } -function matchedReleaseResult(release: TrustedEnclaveRelease): PcrValidationResult { - const { manifest } = release; +function matchedReleaseResult( + release: TrustedEnclaveRelease, + snapshot: TrustedEnclaveReleaseSnapshot +): PcrValidationResult { + const { manifest, sigstore } = release; return { isMatch: true, - text: `PCR0/PCR1/PCR2 match Sigstore-verified ${manifest.environment} release ${manifest.release.tag}`, + text: `PCR0/PCR1/PCR2 match TUF-authorized ${manifest.environment} release ${manifest.release.version}`, environment: manifest.environment, - releaseTag: manifest.release.tag, - sourceCommit: manifest.source.commit, + releaseTag: `v${manifest.release.version}`, + releaseVersion: manifest.release.version, + sourceCommit: manifest.source.revision.digest, sourceRef: manifest.source.ref, - artifactSha256: manifest.artifact.sha256, + artifactSha256: manifest.artifact.digests.sha256, manifestSha256: release.manifestSha256, - bundleSha256: release.bundleSha256, - snapshotId: TRUSTED_RELEASE_SNAPSHOT.snapshotId, - signerIdentity: release.signer.identity, - transparencyLog: release.transparencyLog + bundleSha256: sigstore.bundleSha256, + snapshotId: snapshot.policyId, + channelSequence: snapshot.sequence, + builderId: manifest.build.builderId, + signerIdentityPolicy: sigstore.builder.certificateIdentityRegexp, + oidcIssuer: sigstore.builder.certificateOidcIssuer, + sigstoreTrustedRootSha256: sigstore.trustedRootSha256 }; } export function validatePcrsAgainstSnapshot( pcrs: ReadonlyMap, environment: AttestationEnvironment, - snapshot: TrustedEnclaveReleaseSnapshot = TRUSTED_RELEASE_SNAPSHOT + 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 + }; + } 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: snapshot.snapshotId + snapshotId }; } - - const matches = snapshot.releases.filter((release) => { + const match = snapshot.releases.find((release) => { const expected = release.manifest.measurements.pcrs; return ( release.manifest.environment === environment && @@ -429,72 +211,77 @@ export function validatePcrsAgainstSnapshot( expected["2"] === actualPcrs["2"] ); }); - - if (matches.length === 0) { + if (!match) { return { isMatch: false, - text: `PCR0/PCR1/PCR2 do not match a trusted ${environment} release`, + text: `PCR0/PCR1/PCR2 do not match one active ${environment} release`, environment, - snapshotId: snapshot.snapshotId + snapshotId, + channelSequence: snapshot.sequence }; } + return matchedReleaseResult(match, snapshot); +} - matches.sort((left, right) => - compareReleaseTags(right.manifest.release.tag, left.manifest.release.tag) - ); - return { - ...matchedReleaseResult(matches[0]), - snapshotId: snapshot.snapshotId - }; +export async function resolveTrustedPcrPolicy( + environment: AttestationEnvironment +): Promise { + return await refreshAttestationPolicy(environment); } -function compareReleaseTags(left: string, right: string): number { - const leftParts = left.slice(1).split(".").map(BigInt); - const rightParts = right.slice(1).split(".").map(BigInt); - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] > rightParts[index]) return 1; - if (leftParts[index] < rightParts[index]) return -1; +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.`); + } } - return 0; + // 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; } -export function requireTrustedPcrs( +export async function requireTrustedPcrs( pcrs: ReadonlyMap, environment: AttestationEnvironment -): PcrValidationResult { - const result = validatePcrsAgainstSnapshot(pcrs, environment); - if (!result.isMatch) { - throw new Error(result.text); - } - return result; +): Promise { + const snapshot = await resolveTrustedPcrPolicy(environment); + return await requireTrustedPcrsAgainstSnapshot(pcrs, environment, snapshot); } -/** - * Display-only compatibility helper. PCR0 by itself is never used to authorize - * key exchange; runtime authorization calls requireTrustedPcrs with PCR0/1/2. - */ +/** Display-only helper. PCR0 alone never authorizes key exchange. */ export async function validatePcr0Hash( hash: string, config?: PcrConfig ): Promise { const environment = config?.environment; - const matches = TRUSTED_RELEASE_SNAPSHOT.releases.filter( - (release) => - (!environment || release.manifest.environment === environment) && - release.manifest.measurements.pcrs["0"] === hash - ); - - if (matches.length > 0) { - matches.sort((left, right) => - compareReleaseTags(right.manifest.release.tag, left.manifest.release.tag) - ); - return matchedReleaseResult(matches[0]); + if (!environment) { + return { + isMatch: false, + text: "An attestation environment is required; full PCR0/PCR1/PCR2 verification is required", + snapshotId: "unavailable" + }; } - + 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 trusted release; full PCR0/PCR1/PCR2 verification is required", + text: "PCR0 does not match an active release; full PCR0/PCR1/PCR2 verification is required", environment, - snapshotId: TRUSTED_RELEASE_SNAPSHOT.snapshotId + snapshotId: snapshot.policyId, + channelSequence: snapshot.sequence }; } diff --git a/sdk/src/lib/test/getAttestationSecurity.test.ts b/sdk/src/lib/test/getAttestationSecurity.test.ts index f28dac48c..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"; @@ -16,6 +20,19 @@ const SESSION_KEY = new Uint8Array(32).fill(0x5a); const PCR_CONFIG: PcrConfig = { 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 { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -47,7 +64,8 @@ function dependencies( ): GetAttestationDependencies { return { verifyAttestation: async () => attestationDocument(TRUSTED_PCR0), - requireTrustedPcrs: () => ({ + resolveTrustedPcrPolicy: async () => TEST_POLICY, + requireTrustedPcrsAgainstSnapshot: async () => ({ isMatch: true, text: "PCR tuple matches a test trusted release", environment: "prod", @@ -85,7 +103,7 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const requireTrustedPcrs = mock(() => ({ + const requireTrustedPcrsAgainstSnapshot = mock(async () => ({ isMatch: true, text: "must not validate a missing PCR", snapshotId: "test-snapshot" @@ -95,13 +113,13 @@ describe("attested session establishment", () => { establish( dependencies({ verifyAttestation: async () => attestationDocument(), - requireTrustedPcrs, + requireTrustedPcrsAgainstSnapshot, keyExchange }) ) ).rejects.toThrow(/PCR/i); - expect(requireTrustedPcrs).toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -111,7 +129,7 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const requireTrustedPcrs = mock(() => ({ + const requireTrustedPcrsAgainstSnapshot = mock(async () => ({ isMatch: true, text: "must not validate a malformed PCR", snapshotId: "test-snapshot" @@ -121,13 +139,13 @@ describe("attested session establishment", () => { establish( dependencies({ verifyAttestation: async () => attestationDocument(new Uint8Array(47)), - requireTrustedPcrs, + requireTrustedPcrsAgainstSnapshot, keyExchange }) ) ).rejects.toThrow(/PCR/i); - expect(requireTrustedPcrs).toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -137,7 +155,7 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const requireTrustedPcrs = mock(() => { + const requireTrustedPcrsAgainstSnapshot = mock(async () => { throw new Error("PCR tuple is not trusted"); }); @@ -145,13 +163,13 @@ describe("attested session establishment", () => { establish( dependencies({ verifyAttestation: async () => attestationDocument(new Uint8Array(48)), - requireTrustedPcrs, + requireTrustedPcrsAgainstSnapshot, keyExchange }) ) ).rejects.toThrow(/PCR/i); - expect(requireTrustedPcrs).toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalled(); expect(keyExchange).not.toHaveBeenCalled(); expect(window.sessionStorage.length).toBe(0); }); @@ -161,22 +179,138 @@ describe("attested session establishment", () => { encrypted_session_key: "must-not-be-used", session_id: "must-not-be-created" })); - const requireTrustedPcrs = mock(() => { + const requireTrustedPcrsAgainstSnapshot = mock(async () => { throw new Error("PCR tuple does not match a trusted release"); }); - await expect(establish(dependencies({ requireTrustedPcrs, keyExchange }))).rejects.toThrow( - /PCR/i + await expect( + establish(dependencies({ requireTrustedPcrsAgainstSnapshot, keyExchange })) + ).rejects.toThrow(/PCR/i); + + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledWith( + expect.any(Map), + "prod", + TEST_POLICY ); + expect(keyExchange).not.toHaveBeenCalled(); + expect(window.sessionStorage.length).toBe(0); + }); - expect(requireTrustedPcrs).toHaveBeenCalledWith(expect.any(Map), "prod"); + 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 requireTrustedPcrs = mock(() => ({ + const requireTrustedPcrsAgainstSnapshot = mock(async () => ({ isMatch: true, text: "PCR tuple matches a test trusted release", environment: "prod", @@ -186,7 +320,13 @@ describe("attested session establishment", () => { encrypted_session_key: "test-encrypted-session-key", session_id: "trusted-session" })); - const deps = dependencies({ verifyAttestation, requireTrustedPcrs, keyExchange }); + const resolveTrustedPcrPolicy = mock(async () => TEST_POLICY); + const deps = dependencies({ + verifyAttestation, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot, + keyExchange + }); const established = await establish(deps); const cached = await establish(deps); @@ -194,8 +334,13 @@ describe("attested session establishment", () => { expect(established).toEqual({ sessionKey: SESSION_KEY, sessionId: "trusted-session" }); expect(cached).toEqual(established); expect(verifyAttestation).toHaveBeenCalledTimes(1); - expect(requireTrustedPcrs).toHaveBeenCalledTimes(1); - expect(requireTrustedPcrs).toHaveBeenCalledWith(expect.any(Map), "prod"); + expect(resolveTrustedPcrPolicy).toHaveBeenCalledTimes(1); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledTimes(1); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledWith( + expect.any(Map), + "prod", + TEST_POLICY + ); expect(keyExchange).toHaveBeenCalledTimes(1); expect( window.sessionStorage.getItem( @@ -221,7 +366,7 @@ describe("attested session establishment", () => { establish( dependencies({ verifyAttestation, - requireTrustedPcrs: () => { + requireTrustedPcrsAgainstSnapshot: async () => { throw new Error("PCR tuple is not in changed policy"); }, keyExchange @@ -253,7 +398,7 @@ describe("attested session establishment", () => { await establish(dependencies()); const verifyAttestation = mock(async () => attestationDocument(TRUSTED_PCR0)); - const requireTrustedPcrs = mock(() => { + const requireTrustedPcrsAgainstSnapshot = mock(async () => { throw new Error("PCR tuple belongs to the production environment"); }); const keyExchange = mock(async () => ({ @@ -266,14 +411,18 @@ describe("attested session establishment", () => { await expect( establish( - dependencies({ verifyAttestation, requireTrustedPcrs, keyExchange }), + dependencies({ verifyAttestation, requireTrustedPcrsAgainstSnapshot, keyExchange }), REMOTE_API_URL, developmentPolicy ) ).rejects.toThrow(/PCR/i); expect(verifyAttestation).toHaveBeenCalledTimes(1); - expect(requireTrustedPcrs).toHaveBeenCalledWith(expect.any(Map), "dev"); + expect(requireTrustedPcrsAgainstSnapshot).toHaveBeenCalledWith( + expect.any(Map), + "dev", + TEST_POLICY + ); expect(keyExchange).not.toHaveBeenCalled(); }); @@ -347,25 +496,28 @@ describe("attested session establishment", () => { }); test("bypasses PCR validation only for an exact HTTP loopback API URL", async () => { - const requireTrustedPcrs = mock(() => { + 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(), - requireTrustedPcrs, + resolveTrustedPcrPolicy, + requireTrustedPcrsAgainstSnapshot, keyExchange }), LOCAL_API_URL ); expect(result).toEqual({ sessionKey: SESSION_KEY, sessionId: "local-session" }); - expect(requireTrustedPcrs).not.toHaveBeenCalled(); + expect(resolveTrustedPcrPolicy).not.toHaveBeenCalled(); + expect(requireTrustedPcrsAgainstSnapshot).not.toHaveBeenCalled(); expect(keyExchange).toHaveBeenCalledTimes(1); }); diff --git a/sdk/src/lib/test/integration/attestation.test.ts b/sdk/src/lib/test/integration/attestation.test.ts index 05287d73b..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); @@ -57,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 index 290da08b2..7511c40f8 100644 --- a/sdk/src/lib/test/integration/attestationSession.test.ts +++ b/sdk/src/lib/test/integration/attestationSession.test.ts @@ -67,7 +67,7 @@ test("never reads legacy unversioned session keys", async () => { expect(window.sessionStorage.getItem("sessionId")).toBeNull(); }); -test("rejects expired or cross-policy cached sessions", async () => { +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); @@ -82,6 +82,9 @@ test("rejects expired or cross-policy cached sessions", async () => { throw new Error("fresh attestation required"); }) as typeof fetch; - await expect(getAttestation(false, apiUrl, policy)).rejects.toThrow("fresh attestation required"); + 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 3cf0cfb0d..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 embedded trusted-release validation", + "hosted enclave establishes a session only after TUF-authorized release validation", async () => { const requests: string[] = []; globalThis.fetch = async (input, init) => { diff --git a/sdk/src/lib/test/integration/pcr.test.ts b/sdk/src/lib/test/integration/pcr.test.ts index 190e11c4a..95279a589 100644 --- a/sdk/src/lib/test/integration/pcr.test.ts +++ b/sdk/src/lib/test/integration/pcr.test.ts @@ -1,154 +1,1855 @@ -import { expect, test } from "bun:test"; -import trustedReleaseSnapshotJson from "../../trusted-enclave-releases.generated.json"; +import { describe, expect, test } from "bun:test"; +import { + assertOfficialEmbeddedBootstrapForTesting, + AttestationTrustError, + createAttestationTufClientForTesting +} from "../../attestationTuf"; import { - assertTrustedReleaseSnapshotIntegrity, - getTrustedReleaseSnapshot, normalizeApiBaseUrl, normalizeApiOrigin, resolveAttestationEnvironment, - validatePcr0Hash, - validatePcrsAgainstSnapshot, - type AttestationEnvironment, - type TrustedEnclaveRelease, - type TrustedEnclaveReleaseSnapshot + validatePcrsAgainstSnapshot } from "../../pcr"; +import { + buildTufFixture, + FIXTURE_NOW, + hexPcrMap, + mockFetch, + PCR0, + PCR1, + PCR2 +} from "../tufFixtures"; + +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); +} -const PCR0 = "01".repeat(48); -const PCR1 = "02".repeat(48); -const PCR2 = "03".repeat(48); +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 hexToBytes(value: string): Uint8Array { - return new Uint8Array(value.match(/../g)!.map((byte) => Number.parseInt(byte, 16))); +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 pcrMap(values = { "0": PCR0, "1": PCR1, "2": PCR2 }) { - return new Map([ - [0, hexToBytes(values["0"])], - [1, hexToBytes(values["1"])], - [2, hexToBytes(values["2"])] - ]); +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); + } + } } -function release(tag: string, environment: AttestationEnvironment = "prod"): TrustedEnclaveRelease { - const sourceRef = `refs/tags/${tag}`; +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 { - manifestSha256: "10".repeat(32), - bundleSha256: "11".repeat(32), - signer: { - oidcIssuer: "https://token.actions.githubusercontent.com", - identity: `https://github.com/OpenSecretCloud/opensecret/.github/workflows/release-nitro-eif.yml@${sourceRef}` + get length() { + return backing.length; }, - transparencyLog: { - logIndex: "1234", - logId: "12".repeat(32) + clear: backing.clear.bind(backing), + getItem(key) { + onGet?.(); + return backing.getItem(key); }, - manifest: { - schema: "https://opensecret.cloud/attestations/nitro-eif-release/v1", - environment, - source: { - repository: "OpenSecretCloud/opensecret", - repositoryId: 921901924, - ownerId: 185423582, - ref: sourceRef, - commit: "13".repeat(20) - }, - release: { tag }, - artifact: { - name: `opensecret-${tag}-${environment}.eif`, - mediaType: "application/vnd.aws.nitro.eif", - sha256: "14".repeat(32), - size: 123 - }, - measurements: { - algorithm: "sha384", - requiredPcrs: [0, 1, 2], - pcrs: { "0": PCR0, "1": PCR1, "2": PCR2 } - }, - build: { - system: "nix", - flakeLockSha256: "15".repeat(32), - derivation: `eif-${environment}`, - workflowRun: "https://github.com/OpenSecretCloud/opensecret/actions/runs/1234/attempts/1" - } - } + key: backing.key.bind(backing), + removeItem: backing.removeItem.bind(backing), + setItem: backing.setItem.bind(backing) }; } -function snapshot(releases: TrustedEnclaveRelease[]): TrustedEnclaveReleaseSnapshot { +function storageWithoutCleanup(backing: Storage): Storage { return { - ...getTrustedReleaseSnapshot(), - snapshotId: "16".repeat(32), - releases + 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) }; } -function sortJson(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortJson); - if (value !== null && typeof value === "object") { - const object = value as Record; - return Object.fromEntries( - Object.keys(object) - .sort() - .map((key) => [key, sortJson(object[key])]) - ); - } - return value; +function storageKeepingLatestObservation(backing: Storage): Storage { + const observationPrefix = "opensecret:attestation-tuf:v4:repository-observation:"; + return { + 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); + } + }; } -test("generated trusted-release snapshot ID covers the canonical policy and releases", async () => { - const { snapshotId, ...snapshotPayload } = trustedReleaseSnapshotJson; - const canonical = `${JSON.stringify(sortJson(snapshotPayload), null, 2)}\n`; - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); - const actual = Array.from(new Uint8Array(digest), (byte) => - byte.toString(16).padStart(2, "0") - ).join(""); +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" + } + }); - expect(actual).toBe(snapshotId); - await expect(assertTrustedReleaseSnapshotIntegrity()).resolves.toBeUndefined(); -}); + 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"); -test("authorizes only a complete PCR0/PCR1/PCR2 tuple in the selected environment", () => { - const trusted = snapshot([release("v1.0.0")]); - const valid = validatePcrsAgainstSnapshot(pcrMap(), "prod", trusted); - expect(valid.isMatch).toBe(true); - expect(valid.releaseTag).toBe("v1.0.0"); - expect(valid.environment).toBe("prod"); - expect(valid.transparencyLog).toEqual({ - logIndex: "1234", - logId: "12".repeat(32) - }); - - const changedPcr1 = validatePcrsAgainstSnapshot( - pcrMap({ "0": PCR0, "1": "04".repeat(48), "2": PCR2 }), - "prod", - trusted + expect(policy.environment).toBe("prod"); + expect(policy.releases).toHaveLength(1); + expect(policy.releases[0].sigstore).toMatchObject({ + builder: { + id: "github-opensecret-v1", + certificateOidcIssuer: "https://token.actions.githubusercontent.com" + } + }); + 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", + "policy/builders.json", + "releases/1.0.0/prod/manifest.json" + ]); + expect(persisted.targetBytes["sigstore/trusted_root.json"]).toBeUndefined(); + expect(persisted.targetBytes["releases/1.0.0/prod/manifest.sigstore.json"]).toBeUndefined(); + }); + + 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"); + + 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); + }); + + 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); + + fixture.routes.set(revoked.urls.timestamp, { status: 503 }); + await expect(tuf.refresh("prod")).rejects.toMatchObject({ + code: "POLICY_RELEASE_NOT_ACTIVE" + }); + }); + + 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.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" }); + }); + + 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("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.each([ + ["source repository mismatch", { sourceUri: "https://code.example/opensecret" }], + ["unsafe source path", { sourcePath: "../backend" }], + ["unsafe artifact name", { artifactName: "../backend.eif" }], + ["query-bearing build URI", { runUri: "https://ci.example/runs/1?token=secret" }], + ["invalid identity regexp", { certificateIdentityRegexp: "^(unclosed$" }] + ])("rejects a manifest with %s", async (_description, fixtureOptions) => { + const fixture = await buildTufFixture(fixtureOptions); + await expect(client(fixture).refresh("prod")).rejects.toBeInstanceOf(AttestationTrustError); + }); + + 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" + }); + + const skippedRoot = await buildTufFixture({ + versions: { root: 2 }, + rootEnvelopeVersionOverride: 3 + }); + await expect(client(skippedRoot).refresh("prod")).rejects.toMatchObject({ + code: "TUF_ROOT_CHAIN_INVALID" + }); + }); + + 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(); + } + }); + + 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"); + + 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); + + await expect(client(current).refresh("prod")).resolves.toMatchObject({ sequence: 1 }); + }); + + 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"); + + 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" }); + + 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("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] + } + }); + await client(initial).refresh("prod"); + + 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 + ); + + 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 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(); + + 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]); + } + + 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(fixture.requests.map(({ url }) => url)).toEqual([root34Url]); + fixture.fetch = routedFetch; + + 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]); + + 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("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"); + } + }; + 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); + } ); - expect(changedPcr1.isMatch).toBe(false); - expect(validatePcrsAgainstSnapshot(pcrMap(), "dev", trusted).isMatch).toBe(false); -}); -test("requires all three 48-byte PCR values", () => { - const trusted = snapshot([release("v1.0.0")]); - const missing = pcrMap(); - missing.delete(2); - expect(validatePcrsAgainstSnapshot(missing, "prod", trusted).isMatch).toBe(false); + 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")); + } + }) + }); + 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"); - const short = pcrMap(); - short.set(1, new Uint8Array(47)); - expect(validatePcrsAgainstSnapshot(short, "prod", trusted).isMatch).toBe(false); -}); + 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; -test("identical reproducible tuples across tags select the highest semantic version", () => { - const trusted = snapshot([release("v1.0.9"), release("v1.10.0"), release("v1.2.0")]); - const result = validatePcrsAgainstSnapshot(pcrMap(), "prod", trusted); - expect(result.isMatch).toBe(true); - expect(result.releaseTag).toBe("v1.10.0"); + 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 }); + }); + + 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("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; + }, + 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 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(); + } + }); + + 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("PCR0 compatibility helper cannot authorize the empty production snapshot", async () => { - const result = await validatePcr0Hash(PCR0, { environment: "prod" }); - expect(result.isMatch).toBe(false); - expect(result.text).toContain("full PCR0/PCR1/PCR2 verification is required"); +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"); + + 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 }); + + 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 + }); + + await expect(reloaded.refresh("prod")).resolves.toMatchObject({ sequence: 3 }); + expect(raced).toBe(true); }); test("binds exact official origins to an environment", () => { diff --git a/sdk/src/lib/test/tufFixtures.ts b/sdk/src/lib/test/tufFixtures.ts new file mode 100644 index 000000000..86a399d33 --- /dev/null +++ b/sdk/src/lib/test/tufFixtures.ts @@ -0,0 +1,544 @@ +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; + certificateIdentityRegexp?: 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 builderPolicyPath = "policy/builders.json"; + 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 builderPolicyBytes = jsonBytes({ + schema: "https://attestations.trymaple.ai/schemas/sigstore-builder-policy/v1", + builders: { + "github-opensecret-v1": { + certificateIdentityRegexp: + options.certificateIdentityRegexp ?? + "^https://github\\.com/OpenSecretCloud/opensecret/\\.github/workflows/release\\.yml@refs/tags/v1\\.0\\.0$", + certificateOidcIssuer: "https://token.actions.githubusercontent.com", + workflowRepository: "OpenSecretCloud/opensecret", + workflowName: "release.yml", + workflowTrigger: "workflow_dispatch" + } + } + }); + 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([ + [builderPolicyPath, builderPolicyBytes], + [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, + builderPolicyTarget: { + path: builderPolicyPath, + sha256: targetDescriptors[builderPolicyPath].hashes.sha256 + }, + 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)) + ) + ]) + ); +} diff --git a/sdk/src/lib/trusted-enclave-releases.generated.json b/sdk/src/lib/trusted-enclave-releases.generated.json deleted file mode 100644 index 4383ab52d..000000000 --- a/sdk/src/lib/trusted-enclave-releases.generated.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "policy": { - "oidcIssuer": "https://token.actions.githubusercontent.com", - "sourceRepository": "OpenSecretCloud/opensecret", - "sourceRepositoryId": 921901924, - "sourceRepositoryOwnerId": 185423582, - "workflow": { - "environment": "production-release", - "name": "Nitro EIF Release", - "path": ".github/workflows/release-nitro-eif.yml", - "trigger": "workflow_dispatch" - } - }, - "releases": [], - "schema": "https://opensecret.cloud/sdk/trusted-enclave-releases/v1", - "snapshotId": "f5caf5bcb6abcdae2bac8cde92ce2d3722afc65c9e7bd39c9c5a1f2ad7780052" -} From 9ccbb8adc3aa64a0f495dfdbb392d6c24d946f01 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:11:56 +0000 Subject: [PATCH 7/7] feat: verify Sigstore attestation evidence in clients --- README.md | 15 +- frontend/bun.lock | 12 +- frontend/src-tauri/Cargo.lock | 1 - frontend/src/routes/proof.tsx | 8 +- proxy/Cargo.lock | 1 - proxy/README.md | 10 +- sdk/README.md | 54 +- sdk/bun.lock | 11 + sdk/docs/PLATFORM.md | 2 +- sdk/package-lock.json | 62 +- sdk/package.json | 1 + sdk/rust/Cargo.lock | 1 - sdk/rust/Cargo.toml | 1 - sdk/rust/README.md | 17 +- sdk/rust/src/trusted_release.rs | 664 +++++++++++------- sdk/rust/tests/fixtures/rekor-v2-artifact.txt | 5 + .../fixtures/rekor-v2-bundle.sigstore.fixture | 1 + .../fixtures/rekor-v2-trusted-root.fixture | 123 ++++ sdk/src/lib/attestationSigstore.ts | 4 + sdk/src/lib/attestationTuf.ts | 212 ++---- sdk/src/lib/index.ts | 1 - sdk/src/lib/pcr.ts | 16 +- sdk/src/lib/sigstoreBrowser.ts | 444 ++++++++++++ sdk/src/lib/test/fixtures/README.md | 33 + .../fixtures/sigstore-production-root.json | 102 +++ sdk/src/lib/test/integration/pcr.test.ts | 49 +- sdk/src/lib/test/sigstoreBrowser.test.ts | 233 ++++++ sdk/src/lib/test/tufFixtures.ts | 21 - 28 files changed, 1588 insertions(+), 516 deletions(-) create mode 100644 sdk/rust/tests/fixtures/rekor-v2-artifact.txt create mode 100644 sdk/rust/tests/fixtures/rekor-v2-bundle.sigstore.fixture create mode 100644 sdk/rust/tests/fixtures/rekor-v2-trusted-root.fixture create mode 100644 sdk/src/lib/attestationSigstore.ts create mode 100644 sdk/src/lib/sigstoreBrowser.ts create mode 100644 sdk/src/lib/test/fixtures/README.md create mode 100644 sdk/src/lib/test/fixtures/sigstore-production-root.json create mode 100644 sdk/src/lib/test/sigstoreBrowser.test.ts diff --git a/README.md b/README.md index 69cbb44a4..c505cf4b8 100644 --- a/README.md +++ b/README.md @@ -223,12 +223,15 @@ 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. -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. +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 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 6aa6c4b7e..c2b403660 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -5688,7 +5688,6 @@ dependencies = [ "p256", "percent-encoding", "pin-project", - "regex", "reqwest 0.12.28", "ring", "serde", diff --git a/frontend/src/routes/proof.tsx b/frontend/src/routes/proof.tsx index fc6da3a85..6081d2105 100644 --- a/frontend/src/routes/proof.tsx +++ b/frontend/src/routes/proof.tsx @@ -588,9 +588,9 @@ function ProofFAQ() { 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; Rust clients also verify 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. + 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.

    @@ -803,7 +803,7 @@ function Verify() { /> =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/Cargo.lock b/sdk/rust/Cargo.lock index 49bf8e820..e79fea861 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1569,7 +1569,6 @@ dependencies = [ "percent-encoding", "pin-project", "pretty_assertions", - "regex", "reqwest 0.12.28", "ring", "serde", diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 9bc84f6ed..8ebb66a87 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -34,7 +34,6 @@ sha2 = "0.10" base64 = "0.22" ring = "0.17" # For certificate validation hex = "0.4" # For debug output -regex = "1.11" sigstore-tuf = { version = "=0.11.0", default-features = false } sigstore-verify = { version = "=0.11.0", default-features = false } diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 1fba52cf5..c014bd6db 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -72,7 +72,9 @@ 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 TUF-authenticated builder issuer and certificate-identity expression. +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 @@ -171,12 +173,13 @@ 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. -The authenticated builder target also carries `workflowName` and -`workflowTrigger`. Release promotion validates those and the GitHub certificate -workflow-ref and workflow-SHA extension claims. The Rust verifier currently -enforces the cryptographically bound certificate identity, issuer, repository -linkage, signature, and log proofs; `sigstore-verify` does not expose those -GitHub-specific extensions for an additional runtime comparison. +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 diff --git a/sdk/rust/src/trusted_release.rs b/sdk/rust/src/trusted_release.rs index eea08d22b..aa555e2c5 100644 --- a/sdk/rust/src/trusted_release.rs +++ b/sdk/rust/src/trusted_release.rs @@ -13,7 +13,6 @@ use crate::{ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use fs2::FileExt; use futures::StreamExt; -use regex::Regex; use reqwest::{redirect::Policy as RedirectPolicy, Client, StatusCode, Url}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,10 +22,11 @@ use sigstore_tuf::{ }; use sigstore_verify::{ trust_root::TrustedRoot as SigstoreTrustedRoot, - types::{Bundle, SignatureContent}, + types::{Bundle, HashAlgorithm, SignatureContent}, VerificationPolicy, Verifier, }; use std::{ + borrow::Cow, collections::{BTreeMap, HashSet}, fmt, fs::{File, OpenOptions}, @@ -40,8 +40,6 @@ 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 BUILDER_POLICY_SCHEMA: &str = - "https://attestations.trymaple.ai/schemas/sigstore-builder-policy/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"; @@ -52,14 +50,11 @@ 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_BUILDERS: usize = 32; -const MAX_IDENTITY_REGEXP_BYTES: usize = 2_048; 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_BUILDER_POLICY_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; @@ -1374,7 +1369,6 @@ struct Channel { schema: String, environment: AttestationEnvironment, sequence: u64, - builder_policy_target: TargetReference, sigstore_trusted_root_target: TargetReference, active: Vec, } @@ -1395,23 +1389,6 @@ struct ActiveRelease { bundle_sha256: String, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct BuilderPolicy { - schema: String, - builders: BTreeMap, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct Builder { - certificate_identity_regexp: String, - certificate_oidc_issuer: String, - workflow_repository: String, - workflow_name: String, - workflow_trigger: String, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ReleaseManifest { @@ -1497,7 +1474,6 @@ trait BundleVerifier: Send + Sync { manifest_bytes: &[u8], bundle_bytes: &[u8], trusted_root_bytes: &[u8], - builder: &Builder, ) -> Result<()>; } @@ -1554,13 +1530,161 @@ fn prevent_fallback_after_channel(error: RefreshFailure) -> RefreshFailure { 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], - builder: &Builder, ) -> 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}")) @@ -1569,51 +1693,17 @@ impl BundleVerifier for PortableBundleVerifier { .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 = Bundle::from_json(bundle_json) - .map_err(|error| policy_error(format!("invalid Sigstore bundle: {error}")))?; - 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", - )); - } - if !matches!(&bundle.content, SignatureContent::MessageSignature(_)) { - return Err(policy_error( - "Sigstore bundle must contain a messageSignature", - )); - } - if bundle.verification_material.tlog_entries.is_empty() - || bundle - .verification_material - .tlog_entries - .iter() - .any(|entry| { - entry - .inclusion_proof - .as_ref() - .is_none_or(|proof| proof.checkpoint.is_empty()) - }) - { - return Err(policy_error( - "Sigstore bundle v0.3 must include a Merkle inclusion proof and signed checkpoint for every transparency-log entry", - )); - } - let policy = - VerificationPolicy::default().require_issuer(builder.certificate_oidc_issuer.clone()); - let result = Verifier::new(&trusted_root) - .verify(manifest_bytes, &bundle, &policy) + 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}")))?; - let identity = result - .identity - .ok_or_else(|| policy_error("Sigstore certificate has no identity"))?; - let identity_policy = compile_identity_policy(builder)?; - if !identity_policy - .find(&identity) - .is_some_and(|matched| matched.start() == 0 && matched.end() == identity.len()) - { - return Err(policy_error( - "Sigstore certificate identity does not satisfy the authenticated builder policy", - )); - } Ok(()) } } @@ -1702,18 +1792,6 @@ where }); } - let builders_bytes = get_bound_target( - &mut updater, - &channel.builder_policy_target.path, - &channel.builder_policy_target.sha256, - MAX_BUILDER_POLICY_BYTES, - now, - ) - .await - .map_err(prevent_fallback_after_channel)?; - let builders: BuilderPolicy = parse_json("builder policy", &builders_bytes)?; - validate_builder_policy(&builders)?; - let sigstore_root_bytes = get_bound_target( &mut updater, &channel.sigstore_trusted_root_target.path, @@ -1755,26 +1833,11 @@ where .await .map_err(prevent_fallback_after_channel)?; - // Parsing is necessary to select the TUF-authenticated builder entry, - // but none of these fields become trusted until verification succeeds - // over the exact raw manifest bytes below. + // 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)?; - let builder = builders - .builders - .get(&manifest.build.builder_id) - .ok_or_else(|| { - RefreshFailure::Security(policy_error(format!( - "manifest references unknown builderId '{}'", - manifest.build.builder_id - ))) - })?; - validate_manifest(&manifest, &version, environment, builder)?; - bundle_verifier.verify( - &manifest_bytes, - &bundle_bytes, - &sigstore_root_bytes, - builder, - )?; + 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)?; @@ -2442,13 +2505,6 @@ fn retain_complete_channel( if channel.active.is_empty() { return Ok(()); } - retain_cached_target( - updater, - &channel.builder_policy_target.path, - Some(&channel.builder_policy_target.sha256), - MAX_BUILDER_POLICY_BYTES, - retained, - )?; retain_cached_target( updater, &channel.sigstore_trusted_root_target.path, @@ -2597,21 +2653,11 @@ fn validate_channel(channel: &Channel, environment: AttestationEnvironment) -> R "channel may contain at most {MAX_ACTIVE_RELEASES} active releases" ))); } - if channel.builder_policy_target.path != "policy/builders.json" { - return Err(policy_error( - "builderPolicyTarget.path must be 'policy/builders.json'", - )); - } 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( - "builderPolicyTarget.sha256", - &channel.builder_policy_target.sha256, - SHA256_HEX_LEN, - )?; validate_hex( "sigstoreTrustedRootTarget.sha256", &channel.sigstore_trusted_root_target.sha256, @@ -2620,40 +2666,6 @@ fn validate_channel(channel: &Channel, environment: AttestationEnvironment) -> R Ok(()) } -fn validate_builder_policy(policy: &BuilderPolicy) -> Result<()> { - if policy.schema != BUILDER_POLICY_SCHEMA { - return Err(policy_error(format!( - "unsupported builder policy schema '{}'", - policy.schema - ))); - } - if policy.builders.is_empty() || policy.builders.len() > MAX_BUILDERS { - return Err(policy_error(format!( - "builder policy must contain between 1 and {MAX_BUILDERS} builders" - ))); - } - for (id, builder) in &policy.builders { - validate_identifier("builder ID", id)?; - validate_https_url("certificateOidcIssuer", &builder.certificate_oidc_issuer)?; - validate_workflow_repository(&builder.workflow_repository)?; - validate_nonempty("workflowName", &builder.workflow_name)?; - validate_nonempty("workflowTrigger", &builder.workflow_trigger)?; - compile_identity_policy(builder)?; - } - Ok(()) -} - -fn compile_identity_policy(builder: &Builder) -> Result { - let value = &builder.certificate_identity_regexp; - if value.len() > MAX_IDENTITY_REGEXP_BYTES || !value.starts_with('^') || !value.ends_with('$') { - return Err(policy_error( - "certificateIdentityRegexp must be an anchored expression of at most 2048 bytes", - )); - } - Regex::new(value) - .map_err(|error| policy_error(format!("invalid certificateIdentityRegexp: {error}"))) -} - fn validate_release_targets( active: &ActiveRelease, environment: AttestationEnvironment, @@ -2697,7 +2709,6 @@ fn validate_manifest( manifest: &ReleaseManifest, version: &str, environment: AttestationEnvironment, - builder: &Builder, ) -> Result<()> { if manifest.schema != MANIFEST_SCHEMA { return Err(policy_error(format!( @@ -2741,17 +2752,7 @@ fn validate_manifest( 40, )?; validate_source_path(&manifest.source.path)?; - let source_uri = validate_https_url("source.uri", &manifest.source.uri)?; - let expected_source_path = format!("/{}", builder.workflow_repository); - let source_repository_path = source_uri - .path() - .strip_suffix(".git") - .unwrap_or(source_uri.path()); - if source_repository_path != expected_source_path { - return Err(policy_error( - "manifest source.uri does not match the authenticated builder repository", - )); - } + validate_https_url("source.uri", &manifest.source.uri)?; validate_file_name("artifact.name", &manifest.artifact.name)?; if manifest.artifact.media_type != EIF_MEDIA_TYPE { @@ -2839,25 +2840,6 @@ fn validate_identifier(field: &str, value: &str) -> Result<()> { Ok(()) } -fn validate_workflow_repository(value: &str) -> Result<()> { - let parts = value.split('/').collect::>(); - if parts.len() != 2 - || parts.iter().any(|part| { - part.is_empty() - || matches!(*part, "." | "..") - || part.len() > 128 - || !part - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - }) - { - return Err(policy_error( - "workflowRepository must be an owner/name repository 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!( @@ -4910,7 +4892,6 @@ mod tests { manifest_bytes: &[u8], bundle_bytes: &[u8], trusted_root_bytes: &[u8], - builder: &Builder, ) -> Result<()> { if self.fail { return Err(policy_error("fixture bundle rejected")); @@ -4920,10 +4901,6 @@ mod tests { .contains("opensecret-backend")); assert_eq!(bundle_bytes, b"fixture-bundle"); assert_eq!(trusted_root_bytes, b"fixture-trusted-root"); - assert_eq!( - builder.certificate_oidc_issuer, - "https://token.actions.githubusercontent.com" - ); Ok(()) } } @@ -5149,13 +5126,13 @@ mod tests { fn build_policy_repository( timestamp_expires: &str, - bad_builder_digest: bool, + 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_builder_digest, + bad_sigstore_root_digest, active_releases, &key_pair, 1, @@ -5166,7 +5143,7 @@ mod tests { fn build_policy_repository_generation( timestamp_expires: &str, - bad_builder_digest: bool, + bad_sigstore_root_digest: bool, active_releases: bool, key_pair: &KeyPair, metadata_version: u64, @@ -5175,7 +5152,7 @@ mod tests { ) -> (MemoryRepository, Vec) { build_policy_repository_generation_with_channel_padding( timestamp_expires, - bad_builder_digest, + bad_sigstore_root_digest, active_releases, key_pair, metadata_version, @@ -5188,7 +5165,7 @@ mod tests { #[allow(clippy::too_many_arguments)] fn build_policy_repository_generation_with_channel_padding( timestamp_expires: &str, - bad_builder_digest: bool, + bad_sigstore_root_digest: bool, active_releases: bool, key_pair: &KeyPair, metadata_version: u64, @@ -5201,7 +5178,6 @@ mod tests { 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 builder_path = "policy/builders.json"; let trusted_root_path = "sigstore/trusted_root.json"; let manifest = serde_json::to_vec(&json!({ @@ -5210,7 +5186,7 @@ mod tests { "environment": "prod", "release": { "version": "1.2.3" }, "source": { - "uri": "https://github.com/OpenSecretCloud/opensecret", + "uri": "https://source.example/OpenSecretCloud/opensecret", "path": "nix/enclave", "ref": "refs/tags/v1.2.3", "revision": { "algorithm": "git-sha1", "digest": "a".repeat(40) }, @@ -5232,32 +5208,19 @@ mod tests { }, "build": { "system": "nix", - "builderId": "opensecret-nitro-eif-github-actions", + "builderId": "portable-nix-builder", "derivation": "eif-prod", "flakeLockSha256": "c".repeat(64), - "runUri": "https://github.com/OpenSecretCloud/opensecret/actions/runs/1/attempts/1", + "runUri": "https://ci.example/runs/1", }, })) .unwrap(); let bundle = b"fixture-bundle".to_vec(); - let builders = serde_json::to_vec(&json!({ - "schema": BUILDER_POLICY_SCHEMA, - "builders": { - "opensecret-nitro-eif-github-actions": { - "certificateIdentityRegexp": "^https://github[.]com/OpenSecretCloud/opensecret/[.]github/workflows/release-nitro-eif[.]yml@refs/tags/v1[.]2[.]3$", - "certificateOidcIssuer": "https://token.actions.githubusercontent.com", - "workflowRepository": "OpenSecretCloud/opensecret", - "workflowName": "Nitro EIF Release", - "workflowTrigger": "workflow_dispatch", - }, - }, - })) - .unwrap(); let trusted_root = b"fixture-trusted-root".to_vec(); - let builder_digest = if bad_builder_digest { + let trusted_root_digest = if bad_sigstore_root_digest { "0".repeat(64) } else { - sha256_hex(&builders) + sha256_hex(&trusted_root) }; let active = if active_releases { json!([{ @@ -5273,8 +5236,7 @@ mod tests { "schema": CHANNEL_SCHEMA, "environment": "prod", "sequence": channel_sequence, - "builderPolicyTarget": { "path": builder_path, "sha256": builder_digest }, - "sigstoreTrustedRootTarget": { "path": trusted_root_path, "sha256": sha256_hex(&trusted_root) }, + "sigstoreTrustedRootTarget": { "path": trusted_root_path, "sha256": trusted_root_digest }, "active": active, }); if channel_padding_bytes > 0 { @@ -5284,7 +5246,6 @@ mod tests { let logical_targets = BTreeMap::from([ ("channels/prod.json".to_string(), channel), - (builder_path.to_string(), builders), (trusted_root_path.to_string(), trusted_root), (manifest_path.to_string(), manifest), (bundle_path.to_string(), bundle), @@ -5550,26 +5511,30 @@ mod tests { 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( + 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(&document(1, 2, 3)).is_ok()); + assert!(policy + .verify_attestation_at(&document(1, 2, 3), now) + .is_ok()); - let offline = resolve_policy( + 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"); @@ -5679,7 +5644,7 @@ mod tests { .expect("an authenticated empty channel is a valid deny-all policy"); assert_eq!(policy.sequence(), 8); assert!(matches!( - policy.verify_attestation(&document(1, 2, 3)), + policy.verify_attestation_at(&document(1, 2, 3), now), Err(Error::UnreleasedAttestationPolicy { .. }) )); @@ -5696,7 +5661,7 @@ mod tests { .expect("the cached revoke-all policy must reverify without network access"); assert_eq!(offline.policy_id(), policy.policy_id()); assert!(matches!( - offline.verify_attestation(&document(1, 2, 3)), + offline.verify_attestation_at(&document(1, 2, 3), now), Err(Error::UnreleasedAttestationPolicy { .. }) )); } @@ -6330,13 +6295,14 @@ mod tests { 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( + let error = resolve_policy_with_final_time( repository, Arc::new(SnapshotStore::default()), &root, AttestationEnvironment::Production, now, &FixtureBundleVerifier { fail: false }, + || now, ) .await .unwrap_err(); @@ -6347,13 +6313,14 @@ mod tests { 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( + let error = resolve_policy_with_final_time( repository, Arc::new(SnapshotStore::default()), &root, AttestationEnvironment::Production, now, &FixtureBundleVerifier { fail: false }, + || now, ) .await .unwrap_err(); @@ -6364,13 +6331,14 @@ mod tests { 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( + let error = resolve_policy_with_final_time( repository, Arc::new(SnapshotStore::default()), &root, AttestationEnvironment::Production, now, &FixtureBundleVerifier { fail: true }, + || now, ) .await .unwrap_err(); @@ -6529,32 +6497,12 @@ mod tests { } #[test] - fn builder_identity_policy_must_be_anchored() { - let builder = Builder { - certificate_identity_regexp: "github".to_string(), - certificate_oidc_issuer: "https://token.actions.githubusercontent.com".to_string(), - workflow_repository: "OpenSecretCloud/opensecret".to_string(), - workflow_name: "Nitro EIF Release".to_string(), - workflow_trigger: "workflow_dispatch".to_string(), - }; - assert!(compile_identity_policy(&builder).is_err()); - } - - #[test] - fn builder_identifiers_and_oidc_issuers_match_the_wire_profile() { + 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( - "certificateOidcIssuer", - "https://token.actions.githubusercontent.com" - ) - .is_ok()); - assert!(validate_https_url( - "certificateOidcIssuer", - "https://token.actions.githubusercontent.com?tenant=maple" - ) - .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()); @@ -6828,10 +6776,14 @@ mod tests { #[tokio::test] async fn root_ceiling_requires_exact_404_before_cached_policy_fallback() { - let now: jiff::Timestamp = "2026-08-29T00:00:00Z".parse().unwrap(); + // 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( - "2026-08-30T00:00:00Z", + ×tamp_expires, false, true, &online_pair, @@ -8838,36 +8790,17 @@ mod tests { assert_eq!(merged.high_water, accepted); } - fn cosign_fixture_builder(identity: &str) -> Builder { - Builder { - certificate_identity_regexp: format!("^{identity}$"), - certificate_oidc_issuer: "https://github.com/login/oauth".to_string(), - workflow_repository: "example/example".to_string(), - workflow_name: "fixture".to_string(), - workflow_trigger: "fixture".to_string(), - } - } - #[test] - fn portable_bundle_verification_is_fully_local_and_identity_bound() { + 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"; - let builder = cosign_fixture_builder(r"w[.]vollprecht@gmail[.]com"); PortableBundleVerifier - .verify(artifact, bundle, trusted_root, &builder) + .verify(artifact, bundle, trusted_root) .unwrap(); assert!(PortableBundleVerifier - .verify(b"tampered", bundle, trusted_root, &builder) - .is_err()); - assert!(PortableBundleVerifier - .verify( - artifact, - bundle, - trusted_root, - &cosign_fixture_builder("someone-else@example[.]com"), - ) + .verify(b"tampered", bundle, trusted_root) .is_err()); let mut downgraded: Value = serde_json::from_slice(bundle).unwrap(); @@ -8878,9 +8811,196 @@ mod tests { artifact, &serde_json::to_vec(&downgraded).unwrap(), trusted_root, - &builder, ) .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] 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/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 index 2257aaaf2..8fe9d47c4 100644 --- a/sdk/src/lib/attestationTuf.ts +++ b/sdk/src/lib/attestationTuf.ts @@ -2,6 +2,7 @@ import { decode as decodeBase64, encode as encodeBase64 } from "@stablelib/base6 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/`; @@ -9,7 +10,6 @@ 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 BUILDER_POLICY_SCHEMA = "https://attestations.trymaple.ai/schemas/sigstore-builder-policy/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}$/; @@ -245,7 +245,6 @@ const ChannelSchema = z schema: z.literal(CHANNEL_SCHEMA), environment: EnvironmentSchema, sequence: PositiveSequenceSchema, - builderPolicyTarget: TargetReferenceSchema, sigstoreTrustedRootTarget: TargetReferenceSchema, active: z.array(ActiveReleaseSchema).max(2) }) @@ -333,58 +332,18 @@ const ManifestSchema = z }) .strict(); -const BuilderIdentitySchema = z - .object({ - certificateIdentityRegexp: z.string().min(2).max(2048), - certificateOidcIssuer: z - .string() - .url() - .refine(isExactHttpsUrl, "OIDC issuer must be an exact HTTPS URL"), - workflowRepository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), - workflowName: z.string().min(1).max(512), - workflowTrigger: z.string().min(1).max(128) - }) - .strict() - .refine( - (builder) => - builder.certificateIdentityRegexp.startsWith("^") && - builder.certificateIdentityRegexp.endsWith("$") && - isValidRegexp(builder.certificateIdentityRegexp), - "certificate identity policy must be anchored" - ); - -const BuilderPolicySchema = z - .object({ - schema: z.literal(BUILDER_POLICY_SCHEMA), - builders: z.record( - z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/), - BuilderIdentitySchema - ) - }) - .strict() - .superRefine((policy, context) => { - const ids = Object.keys(policy.builders); - if (ids.length === 0 || ids.length > 32) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["builders"], - message: "builder policy must contain between 1 and 32 builders" - }); - } - }); - export type NitroReleaseManifest = z.infer; -export type AttestationBuilderPolicy = z.infer; -export type AttestationBuilderIdentity = z.infer & { id: string }; export type SigstoreEvidence = { bundleTarget: string; bundleSha256: string; trustedRootTarget: string; trustedRootSha256: string; - builderPolicyTarget: string; - builderPolicySha256: string; - builder: AttestationBuilderIdentity; + transparencyLog: { + logIndex: string; + logId: string; + }; + observerTimestamp: string; }; export type TrustedTufRelease = { @@ -856,15 +815,6 @@ function isSafeArtifactName(name: string): boolean { ); } -function isValidRegexp(value: string): boolean { - try { - new RegExp(value); - return true; - } catch { - return false; - } -} - function fromHex(value: string): Uint8Array { const bytes = new Uint8Array(value.length / 2); for (let index = 0; index < bytes.length; index += 1) { @@ -1502,11 +1452,6 @@ function releaseVersionFromTarget( return parts[1]; } -function sourceUriMatchesRepository(sourceUri: string, workflowRepository: string): boolean { - const pathname = new URL(sourceUri).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); - return pathname === workflowRepository; -} - async function verifyCachedTarget( raw: RawGeneration | LegacyRawGeneration, targets: TargetsEnvelope, @@ -1533,29 +1478,6 @@ async function verifyCachedTarget( return bytes; } -function assertTargetReference( - targets: TargetsEnvelope, - path: string, - expectedSha256: string, - maxBytes: number, - description: string -): TargetFile { - const descriptor = targetDescriptor(targets, path); - if (descriptor.length > maxBytes) { - throw new AttestationTrustError( - "TRUST_SIZE_LIMIT", - `${description} exceeds the ${maxBytes}-byte limit.` - ); - } - if (descriptor.hashes.sha256 !== expectedSha256) { - throw new AttestationTrustError( - "TUF_TARGET_INTEGRITY", - `${description} digest does not match its channel reference.` - ); - } - return descriptor; -} - async function policyFromTargets( raw: RawGeneration | LegacyRawGeneration, root: RootEnvelope, @@ -1579,12 +1501,6 @@ async function policyFromTargets( `The ${raw.environment} channel contains ${channel.environment} policy.` ); } - if (channel.builderPolicyTarget.path !== "policy/builders.json") { - throw new AttestationTrustError( - "POLICY_INVALID", - "builderPolicyTarget.path must be policy/builders.json." - ); - } if (channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json") { throw new AttestationTrustError( "POLICY_INVALID", @@ -1592,27 +1508,15 @@ async function policyFromTargets( ); } - const builderPolicyBytes = await verifyCachedTarget( + const trustedRootBytes = await verifyCachedTarget( raw, - targets, - channel.builderPolicyTarget.path, - channel.builderPolicyTarget.sha256, - MAX_POLICY_TARGET_BYTES, - "builder policy" - ); - const builderPolicy = parseJson(builderPolicyBytes, BuilderPolicySchema, "builder policy"); - const buildersById = new Map( - Object.entries(builderPolicy.builders).map(([id, builder]) => [id, { id, ...builder }]) - ); - const expectedCachedTargets = new Set([channelPath, channel.builderPolicyTarget.path]); - - assertTargetReference( 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(); @@ -1660,26 +1564,29 @@ async function policyFromTargets( "Release manifest version or source ref does not match its target path." ); } - const builder = buildersById.get(manifest.build.builderId); - if (!builder) { - throw new AttestationTrustError( - "POLICY_INVALID", - `Release manifest references unknown builder ${manifest.build.builderId}.` - ); - } - if (!sourceUriMatchesRepository(manifest.source.uri, builder.workflowRepository)) { - throw new AttestationTrustError( - "POLICY_INVALID", - "Release manifest source URI does not match its authenticated builder repository." - ); - } - assertTargetReference( + 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"], @@ -1701,9 +1608,11 @@ async function policyFromTargets( bundleSha256: active.bundleSha256, trustedRootTarget: channel.sigstoreTrustedRootTarget.path, trustedRootSha256: channel.sigstoreTrustedRootTarget.sha256, - builderPolicyTarget: channel.builderPolicyTarget.path, - builderPolicySha256: channel.builderPolicyTarget.sha256, - builder + transparencyLog: { + logIndex: verifiedSigstore.logIndex, + logId: verifiedSigstore.logId + }, + observerTimestamp: verifiedSigstore.observerTimestamp } }); } @@ -2593,13 +2502,10 @@ async function downloadPolicyTargets( `The ${environment} channel contains ${channel.environment} policy.` ); } - if ( - channel.builderPolicyTarget.path !== "policy/builders.json" || - channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json" - ) { + if (channel.sigstoreTrustedRootTarget.path !== "sigstore/trusted_root.json") { throw new AttestationTrustError( "POLICY_INVALID", - "Channel policy or Sigstore trusted-root target path is not the fixed v1 path." + "Channel Sigstore trusted-root target path is not the fixed v1 path." ); } for (const active of channel.active) { @@ -2622,19 +2528,12 @@ async function downloadPolicyTargets( } const referenced: Array<[string, string, number, string, boolean]> = [ - [ - channel.builderPolicyTarget.path, - channel.builderPolicyTarget.sha256, - MAX_POLICY_TARGET_BYTES, - "builder policy", - true - ], [ channel.sigstoreTrustedRootTarget.path, channel.sigstoreTrustedRootTarget.sha256, MAX_TRUST_ROOT_BYTES, "Sigstore trusted root", - false + true ] ]; for (const release of channel.active) { @@ -2646,7 +2545,7 @@ async function downloadPolicyTargets( "release manifest", true ], - [release.bundleTarget, release.bundleSha256, MAX_BUNDLE_BYTES, "Sigstore bundle", false] + [release.bundleTarget, release.bundleSha256, MAX_BUNDLE_BYTES, "Sigstore bundle", true] ); } @@ -2942,7 +2841,7 @@ export class AttestationTufClient { }; await assertCandidateCoversCurrentState(); - await verifyRawGeneration(candidate.raw, bootstrap, this.currentTime(), true); + 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(); @@ -3078,10 +2977,12 @@ export class AttestationTufClient { 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)); + 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)); + if (memory) { + verified.push(await verifyRawGeneration(memory.raw, bootstrap, now, false, false)); + } } return verified; } @@ -3234,14 +3135,14 @@ export class AttestationTufClient { { cause: parsed.error } ); } - const verified = await verifyRawGeneration(stored.raw, bootstrap, now, false); + 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) + ? await verifyRawGeneration(memory.raw, bootstrap, now, false, false) : undefined; const verified = storedGeneration && memoryGeneration @@ -3282,7 +3183,7 @@ export class AttestationTufClient { now: Date ): Promise { try { - await verifyRawGeneration(raw, bootstrap, now, true); + await verifyRawGeneration(raw, bootstrap, now, true, false); return true; } catch (error) { if (error instanceof AttestationTrustError && error.code === "TUF_EXPIRED") return false; @@ -3335,7 +3236,7 @@ export class AttestationTufClient { repositoryHighWater: merged.repository, channelHighWater: mergedChannel }; - return await verifyRawGeneration(raw, bootstrap, now, true); + return await verifyRawGeneration(raw, bootstrap, now, true, false); } private async commit( @@ -3364,9 +3265,11 @@ export class AttestationTufClient { 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)); + 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)); + existing.push(await verifyRawGeneration(stored.raw, bootstrap, now, false, false)); if (channel === generation.raw.environment) storedForEnvironment.push(stored); } } @@ -3407,7 +3310,7 @@ export class AttestationTufClient { }; await assertCandidateCoversJournal(); - await verifyRawGeneration(generation.raw, bootstrap, this.currentTime(), true); + await verifyRawGeneration(generation.raw, bootstrap, this.currentTime(), true, false); if (this.storage) { const key = this.cacheKey(generation); @@ -3429,7 +3332,7 @@ export class AttestationTufClient { 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) + await verifyRawGeneration(stored.raw, bootstrap, this.currentTime(), false, false) ); } } @@ -3614,7 +3517,13 @@ export class AttestationTufClient { 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); + 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); @@ -3657,15 +3566,6 @@ export function assertAttestationPolicyCurrent(policy: VerifiedAttestationPolicy return defaultClient.assertPolicyCurrent(policy); } -/** @internal Test-only client factory; not exported from the package entry point. */ -export function createAttestationTufClientForTesting( - options: Required> & { - storage?: Storage | null; - } -): AttestationTufClient { - return new AttestationTufClient(options); -} - /** @internal Exercises the official embedded-root release sentinel in tests. */ export function assertOfficialEmbeddedBootstrapForTesting(bootstrap: unknown): void { assertOfficialEmbeddedBootstrap(bootstrap); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index f8806602c..02b83d516 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -156,7 +156,6 @@ export { export { ATTESTATION_TUF_BASE_URL, AttestationTrustError, - type AttestationBuilderIdentity, type NitroReleaseManifest, type SigstoreEvidence, type VerifiedAttestationPolicy diff --git a/sdk/src/lib/pcr.ts b/sdk/src/lib/pcr.ts index ab9f293a3..da07a6071 100644 --- a/sdk/src/lib/pcr.ts +++ b/sdk/src/lib/pcr.ts @@ -57,16 +57,12 @@ export type Pcr0ValidationResult = { bundleSha256?: string; snapshotId: string; channelSequence?: number; + /** Audit-only builder label from the authenticated release manifest. */ builderId?: string; - /** Authenticated policy that the promotion pipeline applies to the Sigstore certificate. */ - signerIdentityPolicy?: string; - /** @deprecated Browser runtime does not observe or verify a Sigstore signer identity. */ - signerIdentity?: string; - oidcIssuer?: string; sigstoreTrustedRootSha256?: string; - /** Browser runtime does not interpret transparency evidence from the bundle. */ + /** Locally verified Rekor transparency-log entry. */ transparencyLog?: { logIndex: string; logId: string }; - /** @deprecated Sigstore timestamps are verified by the promotion pipeline. */ + /** Authenticated RFC3161 observer timestamp from the portable bundle. */ verifiedAt?: string; }; @@ -169,9 +165,9 @@ function matchedReleaseResult( snapshotId: snapshot.policyId, channelSequence: snapshot.sequence, builderId: manifest.build.builderId, - signerIdentityPolicy: sigstore.builder.certificateIdentityRegexp, - oidcIssuer: sigstore.builder.certificateOidcIssuer, - sigstoreTrustedRootSha256: sigstore.trustedRootSha256 + sigstoreTrustedRootSha256: sigstore.trustedRootSha256, + transparencyLog: sigstore.transparencyLog, + verifiedAt: sigstore.observerTimestamp }; } 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/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/integration/pcr.test.ts b/sdk/src/lib/test/integration/pcr.test.ts index 95279a589..8d23b5dec 100644 --- a/sdk/src/lib/test/integration/pcr.test.ts +++ b/sdk/src/lib/test/integration/pcr.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import { assertOfficialEmbeddedBootstrapForTesting, - AttestationTrustError, - createAttestationTufClientForTesting + AttestationTufClient, + type AttestationTufClientOptions, + AttestationTrustError } from "../../attestationTuf"; import { normalizeApiBaseUrl, @@ -20,6 +21,22 @@ import { PCR2 } from "../tufFixtures"; +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, @@ -202,10 +219,8 @@ describe("browser TUF attestation policy", () => { expect(policy.environment).toBe("prod"); expect(policy.releases).toHaveLength(1); expect(policy.releases[0].sigstore).toMatchObject({ - builder: { - id: "github-opensecret-v1", - certificateOidcIssuer: "https://token.actions.githubusercontent.com" - } + transparencyLog: { logIndex: "0" }, + observerTimestamp: "2030-01-01T00:00:00.000Z" }); expect( fixture.requests.every(({ url }) => url.startsWith("https://attestations.trymaple.ai/tuf/")) @@ -229,11 +244,12 @@ describe("browser TUF attestation policy", () => { }; expect(Object.keys(persisted.targetBytes).sort()).toEqual([ "channels/prod.json", - "policy/builders.json", - "releases/1.0.0/prod/manifest.json" + "releases/1.0.0/prod/manifest.json", + "releases/1.0.0/prod/manifest.sigstore.json", + "sigstore/trusted_root.json" ]); - expect(persisted.targetBytes["sigstore/trusted_root.json"]).toBeUndefined(); - expect(persisted.targetBytes["releases/1.0.0/prod/manifest.sigstore.json"]).toBeUndefined(); + expect(persisted.targetBytes["sigstore/trusted_root.json"]).toBeString(); + expect(persisted.targetBytes["releases/1.0.0/prod/manifest.sigstore.json"]).toBeString(); }); test("authorizes only one complete PCR tuple and never mixes active releases", async () => { @@ -326,16 +342,21 @@ describe("browser TUF attestation policy", () => { }); test.each([ - ["source repository mismatch", { sourceUri: "https://code.example/opensecret" }], ["unsafe source path", { sourcePath: "../backend" }], ["unsafe artifact name", { artifactName: "../backend.eif" }], - ["query-bearing build URI", { runUri: "https://ci.example/runs/1?token=secret" }], - ["invalid identity regexp", { certificateIdentityRegexp: "^(unclosed$" }] + ["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("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("rejects invalid signatures and non-sequential root rotation", async () => { const badTimestamp = await buildTufFixture({ tamperTimestampSignature: true }); await expect(client(badTimestamp).refresh("prod")).rejects.toMatchObject({ 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/tufFixtures.ts b/sdk/src/lib/test/tufFixtures.ts index 86a399d33..837c3f6f9 100644 --- a/sdk/src/lib/test/tufFixtures.ts +++ b/sdk/src/lib/test/tufFixtures.ts @@ -34,7 +34,6 @@ export type FixtureOptions = { sourcePath?: string; artifactName?: string; runUri?: string; - certificateIdentityRegexp?: string; rootSigningSeed?: number; rootRoleKeySeedsByRootVersion?: Partial>; rootRoleThresholdsByRootVersion?: Partial>; @@ -294,25 +293,10 @@ export async function buildTufFixture(options: FixtureOptions = {}): Promise([ - [builderPolicyPath, builderPolicyBytes], [trustedRootPath, trustedRootBytes], [manifestPath, manifestBytes], [bundlePath, bundleBytes] @@ -414,10 +397,6 @@ export async function buildTufFixture(options: FixtureOptions = {}): Promise