From 2f775ac02feefdbd80f145199169d87212286cdc Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 8 Jan 2026 08:48:01 -0800 Subject: [PATCH 1/3] Proposal for attestation --- docs/guide/attestation-proposal.md | 1458 ++++++++++++++++++++++++++++ 1 file changed, 1458 insertions(+) create mode 100644 docs/guide/attestation-proposal.md diff --git a/docs/guide/attestation-proposal.md b/docs/guide/attestation-proposal.md new file mode 100644 index 0000000..637dc4a --- /dev/null +++ b/docs/guide/attestation-proposal.md @@ -0,0 +1,1458 @@ +# Runtime Integration Attestation Architecture + +> **Status:** Proposal +> **Author:** Architecture Team +> **Date:** January 2026 + +## Problem Statement + +The Trusted Server is a WASM-based edge computing system that handles ad serving with pluggable **integrations** (Prebid, Lockr, Didomi, etc.). Publishers configure these integrations via `trusted-server.toml`. The challenge: + +**If a publisher tweaks an integration configuration, how can vendors (ad-tech companies, SSPs, DSPs) verify at runtime that what's running is the "canonical" certified version vs. a modified deployment?** + +This is critical for: + +1. **Vendor Trust**: Ad networks need assurance that bid requests, consent signals, and tracking aren't being tampered with +2. **Compliance**: GDPR/TCF handling must be verifiable +3. **Revenue Integrity**: Bid manipulation or creative injection detection + +## Why Attest Configuration? + +Attesting the WASM binary alone is insufficient. Here's why configuration requires separate attestation: + +### Configuration Controls Behavior Without Changing Code + +The Trusted Server's behavior is fundamentally determined by its configuration, not just its code. A publisher can dramatically alter how integrations work by changing `trusted-server.toml`: + +```toml +# Example: Publisher could redirect Prebid traffic +[prebid] +server_url = "https://malicious-prebid-proxy.example.com" # Not the real Prebid server +timeout_ms = 50 # Artificially low timeout to suppress bids + +[bidders] +blocked = ["legitimate-competitor"] # Block specific SSPs +``` + +Even with a perfectly attested, unmodified WASM binary, these configuration changes would: + +- Route bid requests through unauthorized intermediaries +- Suppress legitimate bids through aggressive timeouts +- Block competing demand sources + +**Binary attestation proves the code is authentic. Config attestation proves the behavior is approved.** + +### The Trust Boundary Problem + +| What Binary Attestation Proves | What It Does NOT Prove | +| -------------------------------------- | ------------------------------------------- | +| Code was built from a specific commit | Which Prebid servers are configured | +| Build environment wasn't tampered with | What timeout values are set | +| No malicious code was injected | Which bidders are enabled/blocked | +| Integration code is authentic | Whether integration is configured correctly | + +Vendors need both: + +1. **Code attestation**: "This is the real Prebid integration code" +2. **Config attestation**: "This Prebid integration is configured according to our certification requirements" + +### Real-World Attack Scenarios + +Without config attestation, a malicious publisher could: + +1. **Bid Suppression**: Configure extremely low timeouts so certain SSPs always lose +2. **Traffic Hijacking**: Point integration endpoints to proxy servers that skim data +3. **Consent Manipulation**: Configure CMP integrations to report false consent +4. **Revenue Skimming**: Modify bid response handling to replace creative URLs + +All of these attacks use **unmodified code** with **malicious configuration**. + +### Vendor Certification Requirements + +Many ad-tech vendors have certification programs that require specific configurations: + +| Vendor Type | Configuration Requirements | +| ---------------- | ------------------------------------------------------- | +| **SSPs** | Specific server endpoints, minimum timeout values | +| **CMPs** | Correct TCF vendor list, proper consent signal handling | +| **ID Providers** | Approved partner IDs, correct storage domains | +| **Exchanges** | Valid seller.json entries, authorized inventory sources | + +Config attestation enables vendors to verify: _"Is this deployment configured according to my certification requirements?"_ + +### Separation Enables Independent Updates + +With config separated from binary: + +| Scenario | Binary Attestation | Config Attestation | +| ---------------------- | --------------------- | -------------------------------------- | +| Update Prebid timeout | Same hash (unchanged) | New config hash | +| Add new bidder | Same hash (unchanged) | New config hash | +| Upgrade Trusted Server | New binary hash | Same config hash (if config unchanged) | +| Security patch | New binary hash | Same config hash (if config unchanged) | + +This separation allows: + +- Publishers to update config without rebuilding +- Vendors to certify configs independently of code versions +- Clear audit trails showing what changed and when + +## Current Architecture Analysis + +### What Exists Today + +| Component | Current Implementation | +| ---------------------------- | -------------------------------------------------------------------------------------------- | +| **Request Signing** | Ed25519 signatures on OpenRTB request IDs (`crates/common/src/request_signing/signing.rs`) | +| **Key Management** | JWKS at `/.well-known/trusted-server.json`, key rotation via admin endpoints | +| **Build Process** | Cargo → WASM (`wasm32-wasip1`), TS/JS bundled via Vite (`crates/js/build.rs`) | +| **Integration Registration** | Runtime trait-based discovery from `Settings` (`crates/common/src/integrations/registry.rs`) | +| **Configuration** | TOML-based, **embedded at build time**, no runtime validation of config authenticity | + +### Current Limitation: Embedded Configuration + +**In Fastly Compute (and similar edge runtimes), there is no mutable filesystem for runtime configuration.** The current build embeds configuration into the WASM binary at build time: + +```rust +// crates/common/src/settings_data.rs (CURRENT - TO BE REPLACED) +const SETTINGS_DATA: &[u8] = include_bytes!("../../../target/trusted-server-out.toml"); + +pub fn get_settings() -> Result> { + let toml_str = str::from_utf8(SETTINGS_DATA)?; + Settings::from_toml(toml_str) +} +``` + +**Why this must change:** + +1. **Config changes require a rebuild** - publishers need Rust toolchain just to change settings +2. **Binary hash changes for config tweaks** - can't distinguish "code changed" from "config changed" +3. **No multi-platform support** - config is baked into a single deployment artifact +4. **Attestation is all-or-nothing** - vendors can't certify code separately from config + +**Solution:** All major edge platforms provide runtime key-value stores that WASM can access. We will load configuration from these stores instead of embedding it. + +### The Gap + +Currently, request signing proves "this request came from a Trusted Server instance" but **not**: + +- Which WASM binary version is running +- What integration configurations are active (embedded in the binary) +- Whether the deployment matches a vendor-certified build + +## Proposed Solution + +Two foundational architectural decisions enable comprehensive attestation: + +### Core Architecture Decisions + +| Decision | Status | Rationale | +| ------------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Config Separation** | **Required** | Config loaded from edge platform Config Store at runtime, not embedded in WASM. Enables independent attestation of code vs. settings. | +| **Modular Integration Crates** | **Required** | Each integration (Prebid, Lockr, Didomi, etc.) is a separate crate with vendor CODEOWNERS. Enables per-vendor code ownership and review. | + +### Implementation Components + +| Area | Approach | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **Core Attestation** | GitHub Artifact Attestations (Sigstore/SLSA) for WASM binary | +| **Integration Ownership** | Vendor CODEOWNERS in monorepo; vendors review/approve their integration code | +| **Config Store** | Platform-agnostic loading from Fastly Config Store, Cloudflare KV, or Akamai EdgeKV | +| **Runtime Verification** | `/.well-known/trusted-server-attestation.json` exposes binary attestation reference + config hash/metadata + integration metadata | +| **CLI Tooling** | `ts-cli` for config deployment, validation, and attestation across platforms | + +### Implementation Path + +1. **Phase 1 - Foundation** + - Add GitHub Attestations to CI for core binary + - Implement Config Store loading with platform abstraction + - Build `ts-cli` for config management + +2. **Phase 2 - Modular Integrations** + - Extract `integration-api` crate defining stable ABI + - Split integrations into optional feature crates + - Each integration includes embedded attestation metadata + +3. **Phase 3 - Vendor Code Ownership** + - Integrations remain in trusted-server repo with vendor CODEOWNERS + - Vendors review/approve changes to their integration code + - IAB CI attests integrations on behalf of vendors after approval + +## Architecture Diagrams + +### Current Architecture (No Attestation) + +Today, there is **no attestation**. Config is embedded at build time, and the WASM binary is deployed without any provenance verification: + +```mermaid +flowchart TB + subgraph PUBLISHER ["Publisher Build Environment"] + CONFIG["trusted-server.toml
(Publisher's config)"] + SRC["Trusted Server Source
(from GitHub)"] + end + + subgraph BUILD ["Build Process"] + CONFIG --> PREPROCESS["Pre-process config
→ target/trusted-server-out.toml"] + SRC --> CARGO["cargo build"] + PREPROCESS --> EMBED["include_bytes!()
Embed config in binary"] + EMBED --> CARGO + CARGO --> WASM["WASM Binary
(contains embedded config)"] + end + + subgraph DEPLOY ["Deployment"] + WASM --> FASTLY["Fastly Compute Edge"] + end + + subgraph CURRENT_GAP ["No Attestation Today"] + NO_HASH["No binary hash published"] + NO_SIG["No signature verification"] + NO_MANIFEST["No build manifest"] + end + + style CONFIG fill:#FFE4B5,color:#333 + style WASM fill:#FFB6C1,color:#333 + style CURRENT_GAP fill:#FFCCCC,color:#333 +``` + +**Problems with current approach:** + +- No way to verify which code is running +- Config changes require full rebuild +- Vendors cannot verify deployment integrity +- No separation between code and config attestation + +### Target Architecture (Attestation + Config in KV Store) + +The target architecture: + +1. **Config in KV Store** - loaded at runtime, not embedded in WASM +2. **IAB attests the binary** - single attestation covering core + all integrations +3. **Vendor CODEOWNERS** - vendors review their integration code before merge + +```mermaid +sequenceDiagram + autonumber + participant GH as GitHub Actions + participant SIG as Sigstore + participant CFG as Config Store + participant TS as Trusted Server
(Edge/WASM) + participant PUB as Publisher + participant VEN as Vendor
(Prebid/SSP) + + Note over GH,SIG: Build Time (CI/CD) + GH->>GH: Build WASM binary
(code only, no config) + GH->>GH: Compute SHA-256 hash + GH->>SIG: Create SLSA attestation + SIG-->>GH: Signed provenance + transparency log + + Note over PUB,CFG: Config Deployment + PUB->>PUB: ts-cli config push --file config.toml + PUB->>CFG: Upload config to Config Store + + Note over TS,VEN: Runtime + TS->>CFG: Fetch config from Config Store + TS->>TS: Expose attestation via discovery endpoint + + rect rgba(10, 38, 63, 1) + Note over TS,VEN: Ad Request Flow + TS->>VEN: OpenRTB bid request +
attestation claims + VEN->>TS: GET /.well-known/trusted-server-attestation.json + TS-->>VEN: {binary_hash, config_hash} + VEN->>SIG: Verify binary_hash against transparency log + SIG-->>VEN: Binary provenance confirmed + VEN-->>TS: Bid response + end +``` + +### Target: Attestation Data Flow + +```mermaid +flowchart TB + subgraph BUILD ["Build Time"] + SRC[Source Code
+ Integrations] --> CARGO[cargo build] + CARGO --> WASM[WASM Binary] + WASM --> HASH[SHA-256 Hash] + HASH --> ATTEST[GitHub Attestation] + ATTEST --> SIGSTORE[(Sigstore
Transparency Log)] + end + + subgraph DEPLOY ["Deployment"] + WASM --> FASTLY[Fastly Edge] + CONFIG[trusted-server.toml] --> STORE[(Config Store)] + STORE --> FASTLY + end + + subgraph RUNTIME ["Runtime"] + FASTLY --> STARTUP{Startup} + STARTUP --> LOAD[Load Config from Store] + LOAD --> RUNTIME_ATT[RuntimeAttestation] + RUNTIME_ATT --> SIGN[Sign with Ed25519] + SIGN --> EXPOSE[Expose via Endpoints] + end + + subgraph ENDPOINTS ["API Endpoints"] + EXPOSE --> WELL_KNOWN["/.well-known/
trusted-server-attestation.json"] + EXPOSE --> REQUEST["OpenRTB ext.trusted_server
(per-request claims)"] + end + + style SIGSTORE fill:#87CEEB,color:#333 + style STORE fill:#FFE4B5,color:#333 +``` + +### Target: Verification Flow + +Verification is straightforward - vendors verify the binary came from the official IAB repo via Sigstore: + +```mermaid +flowchart LR + subgraph VENDOR ["Vendor (SSP/DSP)"] + REQ[Receive Bid Request] --> FETCH[Fetch Attestation
from Publisher] + FETCH --> VERIFY{Verify} + + VERIFY --> SIG_CHECK[Verify Binary Hash
vs Sigstore] + SIG_CHECK --> GITHUB[Query GitHub
Attestations] + + GITHUB -->|Pass| TRUST[Trust: Binary is official] + GITHUB -->|Fail| REJECT[Reject: Unknown binary] + end + + style TRUST fill:#90EE90,color:#333 + style REJECT fill:#FF6B6B,color:#333 +``` + +Vendors don't need separate integration attestations - the CODEOWNERS process ensures their code was approved before inclusion, and the integration list is metadata for sanity checks. + +## Config Store Architecture (Required) + +To enable proper attestation, **configuration must be separated from the WASM binary**. This is a foundational architectural requirement, not an optional enhancement. + +### Legacy: Config Embedded in Binary + +```mermaid +flowchart LR + subgraph COUPLED ["Current: Tightly Coupled"] + CONFIG[trusted-server.toml] --> BUILD[cargo build] + CODE[Rust Source] --> BUILD + BUILD --> WASM["WASM Binary
(code + config)"] + WASM --> DEPLOY[Fastly Edge] + end + + style WASM fill:#FFB6C1,color:#333 +``` + +**Problems:** + +- Any config change requires a full rebuild +- Binary hash changes even for non-code config tweaks +- Publishers need Rust toolchain to change config +- Can't distinguish "code changed" from "config changed" + +### Target: Decoupled Build and Config + +```mermaid +flowchart LR + subgraph DECOUPLED ["Target: Decoupled"] + CODE[Rust Source] --> BUILD[cargo build] + BUILD --> WASM["WASM Binary
(code only)"] + + CONFIG[trusted-server.toml] --> UPLOAD[Upload to Fastly] + UPLOAD --> STORE[(Fastly Config Store)] + + WASM --> DEPLOY[Fastly Edge] + STORE --> DEPLOY + end + + style WASM fill:#90EE90,color:#333 + style STORE fill:#87CEEB,color:#333 +``` + +**Benefits:** + +- Binary can be attested once per release (code provenance) +- Config can be attested separately (publisher settings) +- Config changes don't require rebuild +- Clear separation: "Is the code trusted?" vs "Is the config compliant?" + +### Multi-Platform Config Store Support + +All major edge platforms provide runtime key-value stores. The architecture must support all of them: + +| Platform | Config Store | API | Consistency | CLI Tool | +| ---------------------- | --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------- | --------------------- | +| **Fastly Compute** | [Config Store](https://www.fastly.com/documentation/reference/api/services/resources/config-store/) | REST API | Near real-time (treat as eventual) | `fastly config-store` | +| **Cloudflare Workers** | [Workers KV](https://developers.cloudflare.com/kv/) | REST API | Eventually consistent (~60s) | `wrangler kv` | +| **Akamai EdgeWorkers** | [EdgeKV](https://www.akamai.com/products/edgekv) | REST API | Eventually consistent (~10s) | `akamai edgekv` | + +### Implementation: Platform-Agnostic Config Loading + +We define a trait for config store access, with platform-specific implementations: + +```rust +// crates/common/src/config_store.rs +use async_trait::async_trait; + +pub const SETTINGS_KEY: &str = "settings"; +pub const SETTINGS_HASH_KEY: &str = "settings-hash"; +pub const SETTINGS_SIGNATURE_KEY: &str = "settings-signature"; +pub const SETTINGS_METADATA_KEY: &str = "settings-metadata"; + +/// Platform-agnostic config store trait +#[async_trait] +pub trait ConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError>; +} + +/// Load settings from the platform's config store +pub async fn get_settings(store: &S) -> Result { + match store.get(SETTINGS_KEY).await? { + Some(toml_str) => { + tracing::info!("Loading config from Config Store"); + Settings::from_toml(&toml_str) + } + None => Err(TrustedServerError::Configuration { + message: format!("No '{}' key found in Config Store", SETTINGS_KEY), + }), + } +} +``` + +Config Store keys are standardized: + +- `settings` contains the UTF-8 TOML payload +- `settings-hash` (optional) contains `sha256:` of the exact bytes stored under `settings` +- `settings-signature` (optional) contains an Ed25519 signature (or DSSE/JWS envelope) over the exact bytes stored under `settings` +- `settings-metadata` (optional) is JSON containing `version`, `published_at` (RFC3339), `valid_until` (RFC3339), and optional `policy_id` (validated policy/schema identifier) + +#### Fastly Implementation + +```rust +// crates/fastly/src/config_store.rs +use async_trait::async_trait; +use fastly::ConfigStore as FastlyStore; + +pub struct FastlyConfigStore { + store: FastlyStore, +} + +impl FastlyConfigStore { + pub fn open(name: &str) -> Result { + let store = FastlyStore::open(name); + Ok(Self { store }) + } +} + +#[async_trait] +impl ConfigStore for FastlyConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.store.get(key)) + } +} +``` + +#### Cloudflare Implementation + +```rust +// crates/cloudflare/src/config_store.rs +use async_trait::async_trait; +use worker::kv::KvStore; + +pub struct CloudflareConfigStore { + kv: KvStore, +} + +impl CloudflareConfigStore { + pub fn from_binding(kv: KvStore) -> Self { + Self { kv } + } +} + +#[async_trait] +impl ConfigStore for CloudflareConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.kv.get(key).text().await + } +} +``` + +#### Akamai Implementation + +```rust +// crates/akamai/src/config_store.rs +use async_trait::async_trait; +use edgekv::EdgeKV; + +pub struct AkamaiConfigStore { + namespace: String, + group: String, +} + +impl AkamaiConfigStore { + pub fn new(namespace: &str, group: &str) -> Self { + Self { + namespace: namespace.to_string(), + group: group.to_string(), + } + } +} + +#[async_trait] +impl ConfigStore for AkamaiConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + EdgeKV::open(&self.namespace, &self.group)?.get_text(key) + } +} +``` + +### Attestation with Decoupled Architecture + +```mermaid +sequenceDiagram + autonumber + participant VEN as Vendor + participant GH as GitHub + participant PUB as Publisher + participant TS as Trusted Server + participant CFG as Fastly Config Store + + Note over GH: Binary Attestation (once per release) + GH->>GH: Build WASM (code only) + GH->>GH: Sign with Sigstore + GH->>GH: Publish binary_hash to transparency log + + Note over PUB,CFG: Config Deployment (per publisher) + PUB->>CFG: Upload trusted-server.toml + CFG->>CFG: Store config + settings-hash + PUB->>PUB: Deploy attested WASM binary + + Note over TS,VEN: Runtime Attestation + TS->>CFG: Load config from Config Store + TS->>TS: Compute config_hash from stored bytes (or read settings-hash) + + VEN->>TS: GET /.well-known/trusted-server-attestation.json + TS-->>VEN: {binary_hash, config_hash} + + VEN->>GH: Verify binary_hash (Sigstore) + VEN->>VEN: Binary is from official IAB repo +``` + +### Attestation Document Structure + +```json +{ + "version": "1.0", + "binary": { + "name": "trusted-server", + "version": "1.5.0", + "hash": "sha256:abc123...", + "git_commit": "def456...", + "sigstore_log_index": 123456, + "attested_by": { + "identity": "iab-tech-lab", + "oidc_issuer": "https://token.actions.githubusercontent.com", + "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" + } + }, + "integrations": [ + { + "name": "prebid", + "enabled": true, + "codeowner": "@prebid/trusted-server-maintainers" + }, + { + "name": "didomi", + "enabled": true, + "codeowner": "@didomi/trusted-server-maintainers" + } + ], + "config": { + "hash": "sha256:789abc...", + "source": "config_store", + "version": "2026-02-15T10:30:00Z", + "published_at": "2026-02-15T10:30:00Z", + "valid_until": "2026-02-16T10:30:00Z", + "policy_id": "prebid-v1" + }, + "signature": "ed25519:...", + "kid": "publisher-2026-A" +} +``` + +**Signature and hashing rules:** + +- Preferred transport is a DSSE envelope; the JSON shown here is the payload. When using DSSE, omit `signature` and `kid` from the payload. +- If a bare JSON payload is used, `signature` is computed over a canonical JSON form of this document with `signature` and `kid` omitted (use RFC 8785 JCS to avoid ambiguity) +- `signature` format is `ed25519:` +- `kid` identifies the deployment signing key published in `/.well-known/trusted-server.json` (reuses the request-signing JWKS) +- `binary.hash` is embedded at build time (the runtime does not compute it from the module) +- `config.hash` is SHA-256 over the exact bytes stored under `settings` (LF-normalized), represented as `sha256:` +- If `settings-signature` is present, it is verified before the config is parsed or used + +Vendors verify `binary.hash` against the Sigstore transparency log and verify `signature` using the JWKS. +If `sigstore_log_index` is present, vendors can resolve the log entry directly without searching. + +#### Attestation Envelope (Recommended) + +Wrap the payload in a DSSE envelope to avoid JSON canonicalization ambiguity and to use standard tooling: + +```json +{ + "payloadType": "application/vnd.iab.trusted-server-attestation+json", + "payload": "eyJ2ZXJzaW9uIjoiMS4wIiwgLi4uIH0", + "signatures": [ + { + "keyid": "publisher-2026-A", + "sig": "m9R0tQ...base64url..." + } + ] +} +``` + +### Implementation Phases + +```mermaid +flowchart TB + subgraph PHASE1 ["Phase 1: Config Store Implementation"] + P1A[Implement ConfigStore trait] + P1B[Add Fastly/Cloudflare/Akamai backends] + P1C[ts-cli config push command] + P1A --> P1B --> P1C + end + + subgraph PHASE2 ["Phase 2: Attestation"] + P2A[Binary attestation via Sigstore] + P2B[Config hash in attestation endpoint] + P2A --> P2B + end + + subgraph PHASE3 ["Phase 3: Vendor CODEOWNERS"] + P3A[Set up CODEOWNERS] + P3B[Vendor teams approve integration changes] + P3A --> P3B + end + + PHASE1 --> PHASE2 --> PHASE3 + + style PHASE1 fill:#90EE90,color:#333 + style PHASE2 fill:#E0FFE0,color:#333 + style PHASE3 fill:#87CEEB,color:#333 +``` + +### Design Considerations + +| Aspect | Design Decision | Rationale | +| ------------------------ | ------------------------------------------------------ | -------------------------------------------------- | +| **Config source** | Config Store only (no embedded fallback in production) | Ensures consistent attestation model | +| **Local development** | Mock config store or env-based loading | `ts-cli` can populate local Viceroy config store | +| **Cold start latency** | Accept slight overhead | Config Store reads are fast (<10ms on Fastly) | +| **Config tampering** | Detectable via hash mismatch | Runtime hash computation + attestation | +| **Config authenticity** | Verify signed config payload | `settings-signature` checked before parsing config | +| **Rollback protection** | Config version + validity window | Prevents replay of stale configs | +| **Attestation envelope** | DSSE (or JWS/COSE) | Avoids canonicalization ambiguity, better tooling | +| **Request binding** | Include `attestation_hash` in signed request | Cryptographically binds runtime claims per request | +| **Provenance lookup** | Include Sigstore log index or bundle | Enables low-latency verification | +| **Multi-platform** | Abstract via trait | Same WASM binary logic, platform-specific backends | +| **Eventual consistency** | Include config version + publish time | Vendors can detect stale configs briefly | + +### CLI Tool: `ts-cli` + +To support the Config Store workflow, we need a CLI tool for managing configuration deployment and attestation. This tool bridges local development with edge deployment. + +#### Commands + +```bash +# Push config to edge platform (auto-detects from config or --platform flag) +ts-cli config push --file trusted-server.toml + +# Push to specific platform +ts-cli config push --platform fastly --store-id --file trusted-server.toml +ts-cli config push --platform cloudflare --namespace --file trusted-server.toml +ts-cli config push --platform akamai --namespace --group --file trusted-server.toml + +# Push with signature + metadata (recommended) +ts-cli config push --platform fastly --store-id --file trusted-server.toml --sign --key signing-key.pem --version 2026-02-15T10:30:00Z + +# Validate config syntax +ts-cli config validate --file trusted-server.toml + +# Show config hash (SHA-256) for attestation +ts-cli config hash --file trusted-server.toml + +# Compare local config with deployed config +ts-cli config diff --platform fastly --store-id --file trusted-server.toml + +# Generate attestation document for config +ts-cli attest config --file trusted-server.toml --sign --key signing-key.pem + +# Pull current config from Config Store (for debugging) +ts-cli config pull --platform fastly --store-id --output current.toml +``` + +#### Architecture + +```mermaid +flowchart LR + subgraph LOCAL ["Local Development"] + TOML["trusted-server.toml"] + CLI["ts-cli"] + end + + subgraph PLATFORMS ["Edge Platforms"] + subgraph FASTLY ["Fastly"] + FCS["Config Store"] + end + subgraph CF ["Cloudflare"] + CFKV["Workers KV"] + end + subgraph AKAMAI ["Akamai"] + EKV["EdgeKV"] + end + end + + subgraph ATTESTATION ["Attestation"] + HASH["Config Hash"] + SIG["Signature"] + DOC["Attestation Doc"] + end + + TOML --> CLI + CLI -->|"--platform fastly"| FCS + CLI -->|"--platform cloudflare"| CFKV + CLI -->|"--platform akamai"| EKV + CLI -->|"attest config"| SIG + HASH --> DOC + SIG --> DOC + + style CLI fill:#90EE90,color:#333 + style DOC fill:#87CEEB,color:#333 + style FCS fill:#FFE4B5,color:#333 + style CFKV fill:#FFE4B5,color:#333 + style EKV fill:#FFE4B5,color:#333 +``` + +#### Config Push Workflow + +```mermaid +sequenceDiagram + participant Dev as Developer + participant CLI as ts-cli + participant Val as Validator + participant API as Platform API + participant CS as Config Store + + Dev->>CLI: ts-cli config push --platform fastly --file config.toml + CLI->>CLI: Normalize line endings + parse TOML + CLI->>Val: Validate against schemas + + alt Validation Failed + Val-->>CLI: Errors + CLI-->>Dev: Validation errors + else Validation Passed + Val-->>CLI: OK + CLI->>CLI: Compute SHA-256 hash of stored bytes + CLI->>API: PUT /resources/stores/config/{id}/item/settings + API->>CS: Store config + CS-->>API: OK + API-->>CLI: 200 OK + CLI->>API: PUT /resources/stores/config/{id}/item/settings-hash + API->>CS: Store hash + opt Signing enabled + CLI->>CLI: Sign payload + emit metadata + CLI->>API: PUT /resources/stores/config/{id}/item/settings-signature + API->>CS: Store signature + CLI->>API: PUT /resources/stores/config/{id}/item/settings-metadata + API->>CS: Store metadata + end + CLI-->>Dev: Config deployed (hash: abc123...) + end +``` + +#### Implementation + +The CLI would be implemented as a Rust binary in `crates/ts-cli/`: + +```rust +// crates/ts-cli/src/main.rs +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "ts-cli")] +#[command(about = "Trusted Server CLI for config and attestation management")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Configuration management + Config { + #[command(subcommand)] + action: ConfigAction, + }, + /// Attestation generation + Attest { + #[command(subcommand)] + action: AttestAction, + }, +} + +#[derive(Clone, ValueEnum)] +enum Platform { + Fastly, + Cloudflare, + Akamai, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Push config to edge platform Config Store + Push { + #[arg(long, short)] + platform: Platform, + #[arg(long, short)] + file: PathBuf, + /// Fastly: store ID, Cloudflare: namespace ID, Akamai: namespace + #[arg(long)] + store_id: Option, + /// Cloudflare: namespace ID (alias for store_id) + #[arg(long)] + namespace: Option, + /// Akamai: group name + #[arg(long)] + group: Option, + /// Sign config payload and upload settings-signature/settings-metadata + #[arg(long)] + sign: bool, + /// Signing key for config payload (Ed25519 private key) + #[arg(long)] + key: Option, + /// Config version identifier (monotonic or timestamp) + #[arg(long)] + version: Option, + /// Optional validity window end (RFC3339) + #[arg(long)] + valid_until: Option, + /// Optional published timestamp (RFC3339, defaults to now) + #[arg(long)] + published_at: Option, + /// Optional policy identifier for vendor compliance + #[arg(long)] + policy_id: Option, + }, + /// Validate config against schemas + Validate { + #[arg(long, short)] + file: PathBuf, + }, + /// Compute and display config hash + Hash { + #[arg(long, short)] + file: PathBuf, + #[arg(long, default_value = "sha256")] + algorithm: String, + }, + /// Compare local vs deployed config + Diff { + #[arg(long, short)] + platform: Platform, + #[arg(long)] + store_id: Option, + #[arg(long)] + namespace: Option, + #[arg(long, short)] + file: PathBuf, + }, + /// Pull config from Config Store + Pull { + #[arg(long, short)] + platform: Platform, + #[arg(long)] + store_id: Option, + #[arg(long)] + namespace: Option, + #[arg(long, short)] + output: PathBuf, + }, +} + +#[derive(Subcommand)] +enum AttestAction { + /// Generate attestation for config + Config { + #[arg(long, short)] + file: PathBuf, + #[arg(long)] + sign: bool, + #[arg(long)] + key: Option, + #[arg(long, default_value = "json")] + format: String, + }, +} +``` + +#### Config Hash Computation + +```rust +// crates/ts-cli/src/hash.rs +use sha2::{Sha256, Digest}; +use std::fs; + +pub fn compute_config_hash(path: &Path) -> Result { + // Read and normalize line endings to avoid OS-dependent hashes + let content = fs::read_to_string(path)?; + let normalized = content.replace("\r\n", "\n"); + + // Compute SHA-256 over the exact bytes that will be stored + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + let hash = hasher.finalize(); + + Ok(format!("sha256:{}", hex::encode(hash))) +} +``` + +`ts-cli` should upload the normalized bytes and store `settings-hash` computed from the same payload so runtime and CLI hashes always match. + +If signing is enabled, `ts-cli` should also emit `settings-signature` over the normalized bytes and write `settings-metadata` with `version`, `published_at` (defaulting to now), and optional `valid_until`/`policy_id`. + +#### Attestation Document Format + +The CLI generates attestation documents compatible with Sigstore/in-toto: + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "trusted-server.toml", + "digest": { + "sha256": "abc123..." + } + } + ], + "predicateType": "https://iab.com/trusted-server/config-attestation/v1", + "predicate": { + "timestamp": "2024-01-15T10:30:00Z", + "publisher": "example.com", + "integrations": { + "prebid": { + "enabled": true, + "version_constraint": "^1.0.0" + }, + "lockr": { + "enabled": true, + "config_hash": "sha256:def456..." + } + }, + "validation": { + "passed": true, + "schemas_checked": ["prebid-v1", "lockr-v1"] + } + } +} +``` + +#### Integration with CI/CD + +```yaml +# .github/workflows/deploy-config.yml +name: Deploy Config + +on: + push: + paths: + - "trusted-server.toml" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ts-cli + run: cargo install --path crates/ts-cli + + - name: Validate config + run: ts-cli config validate --file trusted-server.toml + + - name: Generate attestation + run: | + ts-cli attest config \ + --file trusted-server.toml \ + --sign \ + --key ${{ secrets.SIGNING_KEY }} \ + --format json > attestation.json + + - name: Push to Config Store + env: + FASTLY_API_KEY: ${{ secrets.FASTLY_API_KEY }} + run: | + ts-cli config push \ + --platform fastly \ + --store-id ${{ vars.CONFIG_STORE_ID }} \ + --file trusted-server.toml + + - name: Upload attestation artifact + uses: actions/attest-build-provenance@v1 + with: + subject-path: trusted-server.toml +``` + +#### Environment Variables + +```bash +# Platform selection +TRUSTED_SERVER__PLATFORM=fastly # Default platform (fastly, cloudflare, akamai) + +# Fastly +FASTLY_API_KEY=xxx # Fastly API key +TRUSTED_SERVER__FASTLY__CONFIG_STORE_ID=abc123 +TRUSTED_SERVER__FASTLY__SECRET_STORE_ID=def456 + +# Cloudflare +CLOUDFLARE_API_TOKEN=xxx # Cloudflare API token +CLOUDFLARE_ACCOUNT_ID=xxx # Cloudflare account ID +TRUSTED_SERVER__CLOUDFLARE__KV_NAMESPACE_ID=abc123 + +# Akamai +AKAMAI_EDGEGRID_CONFIG=~/.edgerc # Akamai EdgeGrid credentials +TRUSTED_SERVER__AKAMAI__NAMESPACE=trusted_server +TRUSTED_SERVER__AKAMAI__GROUP=config + +# Attestation +TRUSTED_SERVER__ATTESTATION__SIGNING_KEY_PATH=./key + +# CLI Behavior +TRUSTED_SERVER__CLI__OUTPUT_FORMAT=json # Output format (json, yaml, text) +TRUSTED_SERVER__CLI__VERBOSE=true # Verbose logging +``` + +## Integration Code Ownership (Required Architecture) + +Integrations are part of the trusted-server monorepo, with vendor ownership via GitHub CODEOWNERS. This keeps attestation simple: **IAB attests the entire binary** (including all integrations) via a single Sigstore attestation, just like any other release. + +### Key Principle: Unified Attestation + +The attestation model is identical for core code and integrations: + +| Component | Who Reviews Code | Who Attests Binary | +| ------------------- | -------------------------- | ---------------------- | +| Core trusted-server | IAB maintainers | IAB via GitHub Actions | +| Prebid integration | Prebid team (CODEOWNERS) | IAB via GitHub Actions | +| Lockr integration | AuDigent team (CODEOWNERS) | IAB via GitHub Actions | +| Didomi integration | Didomi team (CODEOWNERS) | IAB via GitHub Actions | + +**Vendors don't need separate integration attestations** - they trust the CODEOWNERS process, and the integration list in the attestation is metadata for sanity checks. + +### CODEOWNERS File + +``` +# .github/CODEOWNERS +# Vendors own their integration directories + +/crates/common/src/integrations/prebid/ @prebid/trusted-server-maintainers +/crates/common/src/integrations/lockr/ @AuDigent/trusted-server-maintainers +/crates/common/src/integrations/didomi/ @didomi/trusted-server-maintainers +/crates/common/src/integrations/permutive/ @permutive/trusted-server-maintainers +``` + +### Attestation Flow + +```mermaid +flowchart TB + subgraph REPO ["IABTechLab/trusted-server Monorepo"] + subgraph CODE ["Code (with CODEOWNERS)"] + CORE_SRC["Core code
(IAB maintainers)"] + PREBID_SRC["integrations/prebid/
CODEOWNER: @prebid"] + LOCKR_SRC["integrations/lockr/
CODEOWNER: @AuDigent"] + end + + subgraph PR ["Pull Request"] + CHANGE["Code Change"] --> REVIEW["Required Reviews:
- IAB for core
- Vendor for their integration"] + REVIEW --> MERGE["Merge to main"] + end + + subgraph CI ["GitHub Actions Release"] + MERGE --> BUILD["Build WASM"] + BUILD --> ATTEST["Attest with Sigstore"] + ATTEST --> SIGSTORE[(Sigstore
Transparency Log)] + end + end + + style SIGSTORE fill:#87CEEB,color:#333 + style ATTEST fill:#90EE90,color:#333 + style REVIEW fill:#FFE4B5,color:#333 +``` + +This is the same flow as the main binary attestation - no special vendor verification needed. + +### Trust Model + +| Actor | What They Trust | How They Verify | +| ------------- | ------------------------------------------- | ----------------------------------------------- | +| **Vendor** | Their code wasn't modified without approval | CODEOWNER approval required for their directory | +| **Publisher** | Binary came from official IAB repo | IAB's Sigstore attestation | +| **IAB** | Vendors approved their integration code | CODEOWNER approval in PR history | + +### Monorepo Structure + +``` +trusted-server/ +├── .github/ +│ └── CODEOWNERS # Vendor ownership rules +├── crates/ +│ ├── common/ +│ │ └── src/ +│ │ └── integrations/ +│ │ ├── mod.rs # Integration registry +│ │ ├── prebid/ # CODEOWNER: @prebid +│ │ ├── lockr/ # CODEOWNER: @AuDigent +│ │ ├── didomi/ # CODEOWNER: @didomi +│ │ └── permutive/ # CODEOWNER: @permutive +│ └── fastly/ # Fastly runtime (IAB owned) +``` + +### Challenges and Mitigations + +| Challenge | Mitigation | +| ------------------------ | ------------------------------------------------------ | +| **Vendor Coordination** | CODEOWNERS ensures vendors are notified of changes | +| **Testing Matrix** | CI tests all integration combinations | +| **Vendor Response Time** | Branch protection rules with timeout for vendor review | + +### Attestation Document + +The attestation document exposed at `/.well-known/trusted-server-attestation.json` includes integration metadata: + +```json +{ + "version": "1.0", + "binary": { + "name": "trusted-server", + "version": "1.5.0", + "hash": "sha256:abc123...", + "git_commit": "a1b2c3d4e5f6...", + "sigstore_log_index": 123456, + "attested_by": { + "identity": "iab-tech-lab", + "oidc_issuer": "https://token.actions.githubusercontent.com", + "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" + } + }, + "integrations": [ + { + "name": "prebid", + "enabled": true, + "codeowner": "@prebid/trusted-server-maintainers" + }, + { + "name": "didomi", + "enabled": true, + "codeowner": "@didomi/trusted-server-maintainers" + } + ], + "config": { + "hash": "sha256:789abc...", + "source": "config_store", + "version": "2026-02-15T10:30:00Z", + "published_at": "2026-02-15T10:30:00Z", + "valid_until": "2026-02-16T10:30:00Z", + "policy_id": "prebid-v1" + }, + "signature": "ed25519:...", + "kid": "publisher-2026-A" +} +``` + +## Detailed Implementation Reference + +This section provides detailed code examples and implementation patterns for the attestation system. + +### GitHub Artifact Attestations (CI/CD) + +Use [GitHub Artifact Attestations](https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) to create SLSA Build Level 2/3 provenance for the WASM binary. + +```yaml +# .github/workflows/release.yml +jobs: + build: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + attestations: write + steps: + - uses: actions/checkout@v4 + + - name: Build WASM + run: cargo build --bin trusted-server-fastly --release --target wasm32-wasip1 + + - name: Generate SBOM + uses: anchore/sbom-action@v0 + with: + path: target/wasm32-wasip1/release/ + + - name: Attest Build Provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: target/wasm32-wasip1/release/trusted-server-fastly.wasm + + - name: Attest SBOM + uses: actions/attest-sbom@v2 + with: + subject-path: target/wasm32-wasip1/release/trusted-server-fastly.wasm + sbom-path: sbom.spdx.json +``` + +**What This Provides:** + +- Sigstore-signed attestation proving the binary was built from specific commit +- SLSA provenance linking artifact → source → build environment +- Verification via `gh attestation verify trusted-server-fastly.wasm -R IABTechLab/trusted-server` + +**Limitation:** This proves build provenance but not runtime state. + +### WASM Binary Signing (Embedded Signatures) + +Optionally, implement [wasm-sign](https://github.com/frehberg/wasm-sign) style signatures embedded in the WASM custom section for additional verification. + +```rust +// New crate: crates/attestation/src/wasm_signature.rs + +/// Signature stored in WASM custom section "ts-attestation" +#[derive(Serialize, Deserialize)] +pub struct WasmAttestation { + /// Ed25519 signature over the WASM module (excluding this section) + pub signature: [u8; 64], + /// Key ID from IAB's JWKS + pub kid: String, + /// Timestamp of signing + pub signed_at: u64, + /// SHA-256 of the WASM module (pre-signature) + pub module_hash: [u8; 32], + /// Version identifier + pub version: String, +} +``` + +**Integration with Fastly:** + +```rust +// crates/fastly/src/main.rs - Add to startup + +fn load_embedded_binary_attestation() -> Result { + // In WASM, we can't read our own module bytes directly at runtime. + // Embed the attestation payload at build time instead. + let attestation_json = include_str!(concat!(env!("OUT_DIR"), "/attestation.json")); + let attestation: WasmAttestation = serde_json::from_str(attestation_json)?; + + // Expose via endpoint for external verification + Ok(attestation) +} +``` + +### Configuration Attestation + +Configuration attestation exposes the config hash for transparency. If `settings-signature` is present, verify it before parsing the config. If `settings-metadata` is present, propagate its `version`, `published_at`, and `valid_until` into the attestation document. This allows anyone to verify that a deployment's configuration hasn't changed unexpectedly. + +```rust +// crates/common/src/attestation/runtime.rs + +/// Integration metadata exposed in runtime attestation +pub struct IntegrationMetadata { + pub name: String, + pub enabled: bool, + /// Optional CODEOWNER handle (if embedded at build time) + pub codeowner: Option, +} + +/// Runtime attestation state computed at startup +pub struct RuntimeAttestation { + /// Binary name (stable identifier) + pub binary_name: String, + /// Binary version + pub version: String, + /// Git commit SHA (embedded at build time) + pub git_commit: String, + /// Sigstore log index for the attested binary (if known) + pub sigstore_log_index: Option, + /// Binary hash (embedded at build time; matches Sigstore attestation) + pub binary_hash: [u8; 32], + /// Hash of current configuration + pub config_hash: [u8; 32], + /// Config version (monotonic or timestamp) + pub config_version: String, + /// Config published timestamp (RFC3339) + pub config_published_at: String, + /// Config validity window end (RFC3339) + pub config_valid_until: Option, + /// Optional policy identifier validated at deploy time + pub config_policy_id: Option, + /// Integrations enabled in this deployment + pub integrations: Vec, + /// Timestamp of attestation computation + pub computed_at: u64, + /// Hash of the attestation payload (for request binding) + pub attestation_hash: [u8; 32], + /// Signature over this attestation (using deployment signing key) + pub signature: String, +} +``` + +Compute `attestation_hash` over the canonical attestation payload (or DSSE payload) before signing; do not include `attestation_hash` inside the payload itself. + +#### Attestation Endpoint + +```rust +// New endpoint: GET /.well-known/trusted-server-attestation.json + +pub async fn handle_attestation_request( + settings: &Settings, +) -> Result> { + let attestation = compute_runtime_attestation(settings)?; + + let body = json!({ + "version": "1.0", + "binary": { + "name": &attestation.binary_name, + "version": &attestation.version, + "git_commit": &attestation.git_commit, + "sigstore_log_index": &attestation.sigstore_log_index, + "hash": format!("sha256:{}", hex::encode(&attestation.binary_hash)), + }, + "config": { + "hash": format!("sha256:{}", hex::encode(&attestation.config_hash)), + "source": "config_store", + "version": &attestation.config_version, + "published_at": &attestation.config_published_at, + "valid_until": &attestation.config_valid_until, + "policy_id": &attestation.config_policy_id, + }, + "integrations": &attestation.integrations, + "signature": &attestation.signature, + "kid": get_current_key_id()?, + }); + + Ok(Response::from_status(StatusCode::OK) + .with_header(header::CONTENT_TYPE, "application/json") + .with_body(serde_json::to_vec(&body)?)) +} +``` + +### Request-Level Attestation Claims + +Embed attestation claims in outgoing requests: + +```rust +// Modify enhance_openrtb_request in prebid.rs + +fn enhance_openrtb_request( + request: &mut Json, + settings: &Settings, + attestation: &RuntimeAttestation, +) -> Result<(), Report> { + if let Some(request_signing_config) = &settings.request_signing { + if request_signing_config.enabled { + // Use a per-request nonce (for example, OpenRTB request ID). + // Include attestation_hash in the request signature payload. + let nonce = request["id"].clone(); + let claims = json!({ + "ts_attestation": { + "binary_hash": format!("sha256:{}", hex::encode(&attestation.binary_hash)), + "binary_version": &attestation.version, + "config_hash": format!("sha256:{}", hex::encode(&attestation.config_hash)), + "attestation_hash": format!("sha256:{}", hex::encode(&attestation.attestation_hash)), + "timestamp": attestation.computed_at, + "valid_until": attestation.config_valid_until, + "nonce": nonce, + } + }); + + request["ext"]["trusted_server"]["attestation"] = claims; + } + } + + Ok(()) +} +``` + +The request signing scheme should cover the attestation claims (or at least `attestation_hash`) so they cannot be replayed or substituted out of band. + +## GitHub Attestations Applicability + +**Are GitHub Attestations usable here?** Yes, as the foundation: + +| GitHub Attestation Feature | Applicability | +| -------------------------- | --------------------------------------------- | +| Build Provenance (SLSA) | Proves WASM was built from specific commit | +| Sigstore Signing | Provides public transparency log | +| SBOM Attestations | Documents dependencies | +| `gh attestation verify` | Allows vendors to verify against GitHub's log | + +**But they're insufficient alone because:** + +1. They attest to _build time_, not _runtime configuration_ +2. They can't detect configuration changes post-deployment +3. Edge deployment (Fastly) has no native integration with Sigstore + +**Solution:** Use GitHub Attestations for build provenance, add runtime attestation for config verification. + +## Security Considerations + +| Threat | Mitigation | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Publisher modifies WASM after signing | Sigstore verification of `binary.hash`; changing the module requires a new deploy | +| Publisher replays old attestation | Timestamp + per-request nonce (e.g., OpenRTB request ID) + freshness window + attestation hash bound to request sign | +| Config store tampering | Verify `settings-signature` before parsing or using config | +| Rollback to stale config | `config_version` + `published_at` + `valid_until` checks | +| Attestation tampering | DSSE/JWS/COSE envelope with verified key id | +| Vendor key compromise | Key rotation, transparency log, short-lived signatures | +| Schema downgrade | Version pinning, schema hash in attestation | +| Side-channel config leaks | Only expose hashes, not actual values | + +## Alternative Approaches Considered + +### TEE-based (Twine/SGX) + +Provides strongest guarantees but Fastly Compute doesn't support SGX enclaves. Would require different deployment target. + +### Browser-based verification (Sigstore-browser) + +Vendors could verify in-browser, but adds latency and complexity to bid flow. + +### Blockchain attestation + +Immutable but slow; doesn't fit real-time bidding latency requirements. + +### Simple hash publication + +Just publish config hash - insufficient because it doesn't indicate _what_ changed or whether it's acceptable. + +## Recommended First Step + +Start with **GitHub Attestations** as it: + +- Requires minimal code changes (~50 lines of YAML) +- Provides immediate value for supply chain security +- Establishes foundation for runtime attestation +- Aligns with industry standards (SLSA, Sigstore) + +## References + +### Attestation & Signing + +- [GitHub Artifact Attestations](https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) +- [SLSA Build Level 3 with GitHub](https://docs.github.com/actions/security-guides/using-artifact-attestations-and-reusable-workflows-to-achieve-slsa-v1-build-level-3) +- [wasm-sign - WASM Module Signing](https://github.com/frehberg/wasm-sign) +- [WASM Module Signatures Proposal](https://github.com/WebAssembly/design/issues/1413) +- [Sigstore for Model Transparency](https://next.redhat.com/2025/04/10/model-authenticity-and-transparency-with-sigstore/) +- [Twine - Trusted WASM Runtime](https://arxiv.org/html/2312.09087v1) + +### Edge Platform Config Stores + +- [Fastly Config Store](https://www.fastly.com/documentation/reference/api/services/resources/config-store/) +- [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) +- [Cloudflare KV - Distributed Configuration](https://developers.cloudflare.com/kv/examples/distributed-configuration-with-workers-kv/) +- [Akamai EdgeKV](https://www.akamai.com/products/edgekv) +- [Akamai EdgeKV CLI](https://github.com/akamai/cli-edgeworkers/blob/master/docs/edgekv_cli.md) From 5810756b9ceab0f383179a969b4d320f32ea51b8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:21:07 -0800 Subject: [PATCH 2/3] Update documentation for attestation proposal --- docs/guide/attestation-proposal.md | 219 +++++++++++++---------------- 1 file changed, 98 insertions(+), 121 deletions(-) diff --git a/docs/guide/attestation-proposal.md b/docs/guide/attestation-proposal.md index 637dc4a..bc00609 100644 --- a/docs/guide/attestation-proposal.md +++ b/docs/guide/attestation-proposal.md @@ -163,20 +163,21 @@ Two foundational architectural decisions enable comprehensive attestation: ### Implementation Path -1. **Phase 1 - Foundation** - - Add GitHub Attestations to CI for core binary - - Implement Config Store loading with platform abstraction - - Build `ts-cli` for config management +1. **Phase 1 - Config Store Implementation** + - Implement `ConfigStore` trait with platform abstraction + - Add Fastly, Cloudflare, and Akamai backends + - Build `ts-cli` for config management (`config push`, `validate`, `hash`) + - Migrate from embedded config to runtime Config Store loading -2. **Phase 2 - Modular Integrations** - - Extract `integration-api` crate defining stable ABI - - Split integrations into optional feature crates - - Each integration includes embedded attestation metadata +2. **Phase 2 - Attestation** + - Add GitHub Attestations (Sigstore/SLSA) to CI for WASM binary + - Implement `/.well-known/trusted-server-attestation.json` endpoint + - Expose config hash in runtime attestation 3. **Phase 3 - Vendor Code Ownership** - - Integrations remain in trusted-server repo with vendor CODEOWNERS + - Set up CODEOWNERS for integration directories - Vendors review/approve changes to their integration code - - IAB CI attests integrations on behalf of vendors after approval + - IAB CI attests the unified binary after vendor approval ## Architecture Diagrams @@ -257,7 +258,7 @@ sequenceDiagram Note over TS,VEN: Ad Request Flow TS->>VEN: OpenRTB bid request +
attestation claims VEN->>TS: GET /.well-known/trusted-server-attestation.json - TS-->>VEN: {binary_hash, config_hash} + TS-->>VEN: DSSE attestation (binary + config) VEN->>SIG: Verify binary_hash against transparency log SIG-->>VEN: Binary provenance confirmed VEN-->>TS: Bid response @@ -420,7 +421,7 @@ Config Store keys are standardized: - `settings` contains the UTF-8 TOML payload - `settings-hash` (optional) contains `sha256:` of the exact bytes stored under `settings` -- `settings-signature` (optional) contains an Ed25519 signature (or DSSE/JWS envelope) over the exact bytes stored under `settings` +- `settings-signature` (optional) contains a DSSE envelope whose payload is the exact bytes stored under `settings` (use `payloadType: application/vnd.iab.trusted-server.config+toml`) - `settings-metadata` (optional) is JSON containing `version`, `published_at` (RFC3339), `valid_until` (RFC3339), and optional `policy_id` (validated policy/schema identifier) #### Fastly Implementation @@ -529,75 +530,84 @@ sequenceDiagram TS->>TS: Compute config_hash from stored bytes (or read settings-hash) VEN->>TS: GET /.well-known/trusted-server-attestation.json - TS-->>VEN: {binary_hash, config_hash} + TS-->>VEN: DSSE attestation (binary + config) VEN->>GH: Verify binary_hash (Sigstore) VEN->>VEN: Binary is from official IAB repo ``` -### Attestation Document Structure +### Runtime Attestation Statement (in-toto) + +Sigstore uses DSSE envelopes carrying in-toto Statements. The runtime attestation should follow the same pattern. ```json { - "version": "1.0", - "binary": { - "name": "trusted-server", - "version": "1.5.0", - "hash": "sha256:abc123...", - "git_commit": "def456...", - "sigstore_log_index": 123456, - "attested_by": { - "identity": "iab-tech-lab", - "oidc_issuer": "https://token.actions.githubusercontent.com", - "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" - } - }, - "integrations": [ - { - "name": "prebid", - "enabled": true, - "codeowner": "@prebid/trusted-server-maintainers" - }, + "_type": "https://in-toto.io/Statement/v1", + "subject": [ { - "name": "didomi", - "enabled": true, - "codeowner": "@didomi/trusted-server-maintainers" + "name": "trusted-server-fastly.wasm", + "digest": { + "sha256": "abc123..." + } } ], - "config": { - "hash": "sha256:789abc...", - "source": "config_store", - "version": "2026-02-15T10:30:00Z", - "published_at": "2026-02-15T10:30:00Z", - "valid_until": "2026-02-16T10:30:00Z", - "policy_id": "prebid-v1" - }, - "signature": "ed25519:...", - "kid": "publisher-2026-A" + "predicateType": "https://iab.com/trusted-server/runtime-attestation/v1", + "predicate": { + "version": "1.0", + "binary": { + "name": "trusted-server", + "version": "1.5.0", + "hash": "sha256:abc123...", + "git_commit": "def456...", + "sigstore_log_index": 123456, + "attested_by": { + "identity": "iab-tech-lab", + "oidc_issuer": "https://token.actions.githubusercontent.com", + "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" + } + }, + "integrations": [ + { + "name": "prebid", + "enabled": true, + "codeowner": "@prebid/trusted-server-maintainers" + }, + { + "name": "didomi", + "enabled": true, + "codeowner": "@didomi/trusted-server-maintainers" + } + ], + "config": { + "hash": "sha256:789abc...", + "source": "config_store", + "version": "2026-02-15T10:30:00Z", + "published_at": "2026-02-15T10:30:00Z", + "valid_until": "2026-02-16T10:30:00Z", + "policy_id": "prebid-v1" + } + } } ``` **Signature and hashing rules:** -- Preferred transport is a DSSE envelope; the JSON shown here is the payload. When using DSSE, omit `signature` and `kid` from the payload. -- If a bare JSON payload is used, `signature` is computed over a canonical JSON form of this document with `signature` and `kid` omitted (use RFC 8785 JCS to avoid ambiguity) -- `signature` format is `ed25519:` -- `kid` identifies the deployment signing key published in `/.well-known/trusted-server.json` (reuses the request-signing JWKS) +- The Statement is wrapped in a DSSE envelope with `payloadType: application/vnd.in-toto+json` +- `keyid` in the DSSE signature identifies the deployment signing key published in `/.well-known/trusted-server.json` (reuses the request-signing JWKS) - `binary.hash` is embedded at build time (the runtime does not compute it from the module) +- `subject.digest.sha256` must match `binary.hash` (without the `sha256:` prefix) - `config.hash` is SHA-256 over the exact bytes stored under `settings` (LF-normalized), represented as `sha256:` - If `settings-signature` is present, it is verified before the config is parsed or used -Vendors verify `binary.hash` against the Sigstore transparency log and verify `signature` using the JWKS. +Vendors verify `binary.hash` against the Sigstore transparency log and verify the DSSE signature using the JWKS. If `sigstore_log_index` is present, vendors can resolve the log entry directly without searching. -#### Attestation Envelope (Recommended) - -Wrap the payload in a DSSE envelope to avoid JSON canonicalization ambiguity and to use standard tooling: +#### Attestation Envelope (Sigstore/DSSE) ```json { - "payloadType": "application/vnd.iab.trusted-server-attestation+json", - "payload": "eyJ2ZXJzaW9uIjoiMS4wIiwgLi4uIH0", + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLC4uLn0", "signatures": [ { "keyid": "publisher-2026-A", @@ -647,7 +657,7 @@ flowchart TB | **Config tampering** | Detectable via hash mismatch | Runtime hash computation + attestation | | **Config authenticity** | Verify signed config payload | `settings-signature` checked before parsing config | | **Rollback protection** | Config version + validity window | Prevents replay of stale configs | -| **Attestation envelope** | DSSE (or JWS/COSE) | Avoids canonicalization ambiguity, better tooling | +| **Attestation envelope** | DSSE (Sigstore standard) | Avoids canonicalization ambiguity, better tooling | | **Request binding** | Include `attestation_hash` in signed request | Cryptographically binds runtime claims per request | | **Provenance lookup** | Include Sigstore log index or bundle | Enables low-latency verification | | **Multi-platform** | Abstract via trait | Same WASM binary logic, platform-specific backends | @@ -914,7 +924,7 @@ pub fn compute_config_hash(path: &Path) -> Result { `ts-cli` should upload the normalized bytes and store `settings-hash` computed from the same payload so runtime and CLI hashes always match. -If signing is enabled, `ts-cli` should also emit `settings-signature` over the normalized bytes and write `settings-metadata` with `version`, `published_at` (defaulting to now), and optional `valid_until`/`policy_id`. +If signing is enabled, `ts-cli` should also emit `settings-signature` as a DSSE envelope over the normalized bytes and write `settings-metadata` with `version`, `published_at` (defaulting to now), and optional `valid_until`/`policy_id`. #### Attestation Document Format @@ -953,6 +963,8 @@ The CLI generates attestation documents compatible with Sigstore/in-toto: } ``` +Publish this Statement as a DSSE envelope (`payloadType: application/vnd.in-toto+json`) to align with Sigstore tooling. + #### Integration with CI/CD ```yaml @@ -1123,45 +1135,26 @@ trusted-server/ ### Attestation Document -The attestation document exposed at `/.well-known/trusted-server-attestation.json` includes integration metadata: +The attestation document exposed at `/.well-known/trusted-server-attestation.json` is a DSSE envelope whose payload is the in-toto Statement shown earlier. Integration metadata lives under `predicate.integrations`: ```json { - "version": "1.0", - "binary": { - "name": "trusted-server", - "version": "1.5.0", - "hash": "sha256:abc123...", - "git_commit": "a1b2c3d4e5f6...", - "sigstore_log_index": 123456, - "attested_by": { - "identity": "iab-tech-lab", - "oidc_issuer": "https://token.actions.githubusercontent.com", - "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" - } - }, - "integrations": [ - { - "name": "prebid", - "enabled": true, - "codeowner": "@prebid/trusted-server-maintainers" - }, - { - "name": "didomi", - "enabled": true, - "codeowner": "@didomi/trusted-server-maintainers" - } - ], - "config": { - "hash": "sha256:789abc...", - "source": "config_store", - "version": "2026-02-15T10:30:00Z", - "published_at": "2026-02-15T10:30:00Z", - "valid_until": "2026-02-16T10:30:00Z", - "policy_id": "prebid-v1" - }, - "signature": "ed25519:...", - "kid": "publisher-2026-A" + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://iab.com/trusted-server/runtime-attestation/v1", + "predicate": { + "integrations": [ + { + "name": "prebid", + "enabled": true, + "codeowner": "@prebid/trusted-server-maintainers" + }, + { + "name": "didomi", + "enabled": true, + "codeowner": "@didomi/trusted-server-maintainers" + } + ] + } } ``` @@ -1293,14 +1286,12 @@ pub struct RuntimeAttestation { pub integrations: Vec, /// Timestamp of attestation computation pub computed_at: u64, - /// Hash of the attestation payload (for request binding) + /// Hash of the in-toto Statement payload (for request binding) pub attestation_hash: [u8; 32], - /// Signature over this attestation (using deployment signing key) - pub signature: String, } ``` -Compute `attestation_hash` over the canonical attestation payload (or DSSE payload) before signing; do not include `attestation_hash` inside the payload itself. +Compute `attestation_hash` over the in-toto Statement bytes before signing; do not include `attestation_hash` inside the payload itself. #### Attestation Endpoint @@ -1312,31 +1303,17 @@ pub async fn handle_attestation_request( ) -> Result> { let attestation = compute_runtime_attestation(settings)?; - let body = json!({ - "version": "1.0", - "binary": { - "name": &attestation.binary_name, - "version": &attestation.version, - "git_commit": &attestation.git_commit, - "sigstore_log_index": &attestation.sigstore_log_index, - "hash": format!("sha256:{}", hex::encode(&attestation.binary_hash)), - }, - "config": { - "hash": format!("sha256:{}", hex::encode(&attestation.config_hash)), - "source": "config_store", - "version": &attestation.config_version, - "published_at": &attestation.config_published_at, - "valid_until": &attestation.config_valid_until, - "policy_id": &attestation.config_policy_id, - }, - "integrations": &attestation.integrations, - "signature": &attestation.signature, - "kid": get_current_key_id()?, - }); + let statement = build_runtime_statement(&attestation)?; + let envelope = dsse::sign( + serde_json::to_vec(&statement)?, + "application/vnd.in-toto+json", + &load_signing_key()?, + get_current_key_id()?, + )?; Ok(Response::from_status(StatusCode::OK) .with_header(header::CONTENT_TYPE, "application/json") - .with_body(serde_json::to_vec(&body)?)) + .with_body(serde_json::to_vec(&envelope)?)) } ``` @@ -1406,7 +1383,7 @@ The request signing scheme should cover the attestation claims (or at least `att | Publisher replays old attestation | Timestamp + per-request nonce (e.g., OpenRTB request ID) + freshness window + attestation hash bound to request sign | | Config store tampering | Verify `settings-signature` before parsing or using config | | Rollback to stale config | `config_version` + `published_at` + `valid_until` checks | -| Attestation tampering | DSSE/JWS/COSE envelope with verified key id | +| Attestation tampering | DSSE envelope with verified key id | | Vendor key compromise | Key rotation, transparency log, short-lived signatures | | Schema downgrade | Version pinning, schema hash in attestation | | Side-channel config leaks | Only expose hashes, not actual values | From 504a99ff0cd0fd1595573172dacb119fec5d464e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:52:01 -0800 Subject: [PATCH 3/3] Separate config and build for attestation --- .cargo/config.toml | 3 +- .github/workflows/format.yml | 7 +- .github/workflows/test.yml | 11 +- Cargo.lock | 379 +++++++++++++++- Cargo.toml | 14 +- crates/cli/Cargo.toml | 33 ++ crates/cli/README.md | 110 +++++ crates/cli/src/config.rs | 406 ++++++++++++++++++ crates/cli/src/error.rs | 102 +++++ crates/cli/src/hash.rs | 77 ++++ crates/cli/src/local.rs | 165 +++++++ crates/cli/src/main.rs | 184 ++++++++ crates/cli/src/platform.rs | 145 +++++++ crates/common/Cargo.toml | 14 +- crates/common/build.rs | 85 ---- crates/common/src/config_store.rs | 126 ++++++ crates/common/src/fastly_storage.rs | 30 +- crates/common/src/lib.rs | 1 + crates/common/src/request_signing/jwks.rs | 4 +- crates/common/src/request_signing/rotation.rs | 4 +- crates/common/src/request_signing/signing.rs | 6 +- crates/common/src/settings.rs | 21 +- crates/common/src/settings_data.rs | 320 ++++++++++++-- crates/fastly/src/main.rs | 30 +- docs/README.md | 15 +- docs/guide/attestation-proposal.md | 73 ++-- docs/guide/configuration.md | 57 ++- docs/guide/getting-started.md | 101 ++++- fastly.toml | 4 + 29 files changed, 2311 insertions(+), 216 deletions(-) create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/README.md create mode 100644 crates/cli/src/config.rs create mode 100644 crates/cli/src/error.rs create mode 100644 crates/cli/src/hash.rs create mode 100644 crates/cli/src/local.rs create mode 100644 crates/cli/src/main.rs create mode 100644 crates/cli/src/platform.rs delete mode 100644 crates/common/build.rs create mode 100644 crates/common/src/config_store.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index e0e3f5b..b7ce682 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,5 +4,6 @@ test_details = ["test", "--target", "aarch64-apple-darwin"] [build] target = "wasm32-wasip1" +# Viceroy runs from crate directory, use wrapper to cd to repo root first [target.'cfg(all(target_arch = "wasm32"))'] -runner = "viceroy run -C ../../fastly.toml -- " +runner = ["sh", "-c", "cd ../.. && viceroy run -C fastly.toml -- \"$1\"", "--"] diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 45ab1ae..798b795 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -41,8 +41,11 @@ jobs: - name: Run cargo fmt uses: actions-rust-lang/rustfmt@v1 - - name: Run cargo clipply - run: cargo clippy --all-targets --all-features + - name: Run cargo clippy (wasm - default crates) + run: cargo clippy --target wasm32-wasip1 + + - name: Run cargo clippy (native - cli) + run: cargo clippy -p cli --target x86_64-unknown-linux-gnu format-typescript: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bcd0a64..843a69f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,9 +40,18 @@ jobs: - name: Setup Viceroy (from main since 0.14.3 is broken) run: cargo install --git https://github.com/fastly/Viceroy viceroy - - name: Run tests + - name: Build CLI + run: cargo build -p cli --target x86_64-unknown-linux-gnu + + - name: Generate local config store + run: ./target/x86_64-unknown-linux-gnu/debug/tscli config local -f trusted-server.toml + + - name: Run tests (wasm - default crates) run: cargo test + - name: Run tests (native - cli) + run: cargo test -p cli --target x86_64-unknown-linux-gnu + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 1a2227d..77cc26d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -256,6 +306,73 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cli" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "config", + "error-stack", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.17", + "toml", + "trusted-server-common", + "ureq", + "urlencoding", + "validator", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "config" version = "0.15.19" @@ -317,10 +434,29 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -572,6 +708,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -691,7 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -765,6 +910,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fern" version = "0.7.1" @@ -1018,6 +1169,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -1043,6 +1200,12 @@ dependencies = [ "itoa", ] +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -1194,6 +1357,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1299,6 +1468,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1450,6 +1625,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1813,6 +1994,20 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.0" @@ -1882,7 +2077,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2177,6 +2407,19 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2469,6 +2712,44 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.7" @@ -2487,12 +2768,24 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.18.1" @@ -2600,6 +2893,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.0" @@ -2670,6 +2972,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2679,6 +2990,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.14" diff --git a/Cargo.toml b/Cargo.toml index 9545c84..6776b06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,16 @@ [workspace] resolver = "2" members = [ + "crates/cli", "crates/common", - "crates/fastly", + "crates/fastly", "crates/js", ] -# Build defaults exclude the web-only tsjs crate, which is compiled via wasm-pack. +# Build defaults for wasm32 target (used by `cargo build`). +# Excludes: +# - crates/js: compiled via wasm-pack +# - crates/cli: native-only, build with `cargo build -p cli --target ` default-members = [ "crates/common", "crates/fastly", @@ -22,6 +26,7 @@ brotli = "8.0" bytes = "1.11" chacha20poly1305 = "0.10" chrono = "0.4.42" +clap = { version = "4.5", features = ["derive", "env"] } config = "0.15.19" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } @@ -39,8 +44,8 @@ jose-jwk = "0.1.2" log = "0.4.28" log-fastly = "0.11.12" lol_html = "2.7.0" -once_cell = "1.21" matchit = "0.9" +once_cell = "1.21" pin-project-lite = "0.2" rand = "0.8" regex = "1.12.2" @@ -48,9 +53,12 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.10.9" temp-env = "0.3.6" +tempfile = "3.14" +thiserror = "2.0" tokio = { version = "1.48", features = ["sync", "macros", "io-util", "rt", "time"] } tokio-test = "0.4" toml = "0.9.8" +ureq = { version = "3.0", features = ["json"] } url = "2.5.7" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..2db05fa --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cli" +version = "0.1.0" +authors = ["IAB Technology Laboratory"] +edition = "2021" +description = "CLI tool for Trusted Server configuration management and attestation" +publish = false +license = "Apache-2.0" + +[[bin]] +name = "tscli" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true, features = ["derive", "env"] } +config = { workspace = true } +error-stack = { workspace = true } +hex = { workspace = true } +ureq = { workspace = true, features = ["json"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +urlencoding = { workspace = true } +validator = { workspace = true } +chrono = { workspace = true } + +# For loading Settings struct and validation +trusted-server-common = { path = "../common", default-features = false } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/cli/README.md b/crates/cli/README.md new file mode 100644 index 0000000..e402127 --- /dev/null +++ b/crates/cli/README.md @@ -0,0 +1,110 @@ +# Trusted Server CLI (`tscli`) + +CLI tool for Trusted Server configuration management. + +## Installation + +The workspace defaults to the wasm32 target, so you must specify the native target: + +```bash +# macOS (Apple Silicon) +cargo install --path crates/cli --target aarch64-apple-darwin + +# macOS (Intel) +cargo install --path crates/cli --target x86_64-apple-darwin + +# Linux +cargo install --path crates/cli --target x86_64-unknown-linux-gnu +``` + +Or build directly: + +```bash +cargo build -p cli --release --target aarch64-apple-darwin +``` + +## Commands + +### Validate Configuration + +Validate a TOML configuration file: + +```bash +tscli config validate -f trusted-server.toml +tscli config validate -f trusted-server.toml -v # verbose +``` + +### Compute Hash + +Compute SHA-256 hash of a configuration file (after applying `TRUSTED_SERVER__` overrides by default): + +```bash +tscli config hash -f trusted-server.toml +tscli config hash -f trusted-server.toml --format json +tscli config hash -f trusted-server.toml --raw # hash the file as-is +``` + +### Local Development + +Generate config store JSON for local development with `fastly compute serve`: + +```bash +tscli config local -f trusted-server.toml +``` + +This outputs to `target/trusted-server-config.json` by default. The `fastly.toml` is already configured to read from this path. + +To specify a custom output path: + +```bash +tscli config local -f trusted-server.toml -o custom-path.json +``` + +### Push Configuration + +Push configuration to Fastly Config Store: + +```bash +export FASTLY_API_TOKEN=xxx +tscli config push -f trusted-server.toml --store-id +``` + +Preview without uploading (dry run): + +```bash +tscli config push -f trusted-server.toml --store-id --dry-run +``` + +### Pull Configuration + +Pull configuration from Fastly Config Store: + +```bash +export FASTLY_API_TOKEN=xxx +tscli config pull --store-id -o pulled-config.toml +``` + +### Compare Configurations + +Compare local config with deployed config: + +```bash +export FASTLY_API_TOKEN=xxx +tscli config diff -f trusted-server.toml --store-id +tscli config diff -f trusted-server.toml --store-id -v # verbose diff +``` + +## Environment Variables + +- `FASTLY_API_TOKEN` - Required for push, pull, and diff commands +- `TRUSTED_SERVER__*` - Config values can be overridden via environment variables (e.g., `TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com`) + +## Local Development Workflow + +```bash +# 1. Generate config store JSON +tscli config local -f trusted-server.toml + +# 2. Run local Fastly server +fastly compute serve +``` diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs new file mode 100644 index 0000000..b077447 --- /dev/null +++ b/crates/cli/src/config.rs @@ -0,0 +1,406 @@ +//! Configuration management commands. +//! +//! Configuration is loaded from TOML files and merged with environment variables +//! prefixed with `TRUSTED_SERVER__`. For example, `TRUSTED_SERVER__PUBLISHER__DOMAIN` +//! will override `publisher.domain` in the TOML file. + +use std::fs; +use std::path::PathBuf; + +use trusted_server_common::config_store::{compute_settings_hash, SETTINGS_HASH_KEY, SETTINGS_KEY}; +use trusted_server_common::settings::Settings; +use validator::Validate; + +use crate::error::CliError; +use crate::platform::create_client; +use crate::Platform; + +/// Load and merge configuration from TOML file with environment variables. +/// +/// Environment variables prefixed with `TRUSTED_SERVER__` will override TOML values. +/// For example: `TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com` +pub(crate) fn load_and_merge_config( + file: &PathBuf, + verbose: bool, +) -> Result<(Settings, String), CliError> { + let content = fs::read_to_string(file)?; + + if verbose { + println!("Loading config from: {}", file.display()); + println!("Environment variables with TRUSTED_SERVER__ prefix will be merged"); + } + + // Parse TOML and merge with environment variables + let settings = Settings::from_toml(&content) + .map_err(|e| CliError::Config(format!("Failed to parse and merge config: {:?}", e)))?; + + settings + .validate() + .map_err(|e| CliError::Config(format!("Settings validation failed: {e}")))?; + + let merged_toml = settings + .to_canonical_toml() + .map_err(|e| CliError::Config(format!("Failed to serialize merged config: {e:?}")))?; + + Ok((settings, merged_toml)) +} + +/// Push configuration to edge platform Config Store. +/// +/// The configuration is first merged with environment variables, then pushed. +pub fn push( + platform: Platform, + file: PathBuf, + store_id: String, + dry_run: bool, + verbose: bool, +) -> Result<(), CliError> { + // Load, validate, and merge with env vars + let (settings, merged_toml) = load_and_merge_config(&file, verbose)?; + + // Compute hash of the merged config + let hash = compute_settings_hash(&merged_toml); + + if verbose { + println!("Config file: {}", file.display()); + println!("Publisher domain: {}", settings.publisher.domain); + println!("Config hash: {}", hash); + println!("Platform: {}", platform); + } + + if dry_run { + println!("\n[Dry Run] Would upload the following:"); + println!(" Key '{}': {} bytes", SETTINGS_KEY, merged_toml.len()); + println!(" Key '{}': {}", SETTINGS_HASH_KEY, hash); + if verbose { + println!("\nMerged configuration preview:"); + println!("---"); + // Show first 50 lines + for line in merged_toml.lines().take(50) { + println!("{}", line); + } + if merged_toml.lines().count() > 50 { + println!("... (truncated)"); + } + println!("---"); + } + return Ok(()); + } + + // Create platform client and push + let client = create_client(&platform, store_id)?; + + if verbose { + println!("\nUploading settings..."); + } + + client.put(SETTINGS_KEY, &merged_toml)?; + client.put(SETTINGS_HASH_KEY, &hash)?; + + println!( + "Successfully pushed configuration to {} Config Store", + platform + ); + println!(" Settings hash: {}", hash); + + Ok(()) +} + +/// Validate configuration file. +/// +/// Validates TOML syntax, required fields, and merges with environment variables. +pub fn validate(file: PathBuf, verbose: bool) -> Result<(), CliError> { + // Load, validate, and merge with env vars + let (settings, merged_toml) = load_and_merge_config(&file, verbose)?; + + // Compute hash of merged config + let hash = compute_settings_hash(&merged_toml); + + println!("Configuration is valid"); + println!(" File: {}", file.display()); + println!(" Hash: {}", hash); + println!(" Publisher domain: {}", settings.publisher.domain); + + if verbose { + // Parse the merged TOML to show sections + let value: toml::Value = toml::from_str(&merged_toml)?; + if let Some(table) = value.as_table() { + println!("\nSections found:"); + for key in table.keys() { + println!(" - [{}]", key); + } + } + + // Show active integrations + println!("\nIntegrations:"); + if let Some(integrations) = value.get("integrations").and_then(|v| v.as_table()) { + for (name, config) in integrations { + let enabled = config + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + println!( + " - {}: {}", + name, + if enabled { "enabled" } else { "disabled" } + ); + } + } + } + + Ok(()) +} + +/// Compare local config with deployed config. +pub fn diff( + platform: Platform, + store_id: String, + file: PathBuf, + verbose: bool, +) -> Result<(), CliError> { + // Load and merge local config + let (_settings, local_merged) = load_and_merge_config(&file, verbose)?; + let local_hash = compute_settings_hash(&local_merged); + + if verbose { + println!("Local file: {}", file.display()); + println!("Local hash (after env merge): {}", local_hash); + } + + // Create platform client and fetch remote + let client = create_client(&platform, store_id)?; + + let remote_hash = client.get(SETTINGS_HASH_KEY)?; + let remote_content = client.get(SETTINGS_KEY)?; + + match (remote_hash, remote_content) { + (Some(rh), Some(rc)) => { + println!("Local hash: {}", local_hash); + println!("Remote hash: {}", rh); + + if local_hash == rh { + println!("\nConfigurations are identical."); + } else { + println!("\nConfigurations differ!"); + + if verbose { + // Show a simple diff + let local_lines: Vec<&str> = local_merged.lines().collect(); + let remote_lines: Vec<&str> = rc.lines().collect(); + + println!("\n--- Remote"); + println!("+++ Local (merged with env vars)"); + + for (i, (local, remote)) in + local_lines.iter().zip(remote_lines.iter()).enumerate() + { + if local != remote { + println!("@@ line {} @@", i + 1); + println!("-{}", remote); + println!("+{}", local); + } + } + + // Handle different lengths + if local_lines.len() > remote_lines.len() { + println!("\n+++ Additional local lines:"); + for line in local_lines.iter().skip(remote_lines.len()) { + println!("+{}", line); + } + } else if remote_lines.len() > local_lines.len() { + println!("\n--- Additional remote lines:"); + for line in remote_lines.iter().skip(local_lines.len()) { + println!("-{}", line); + } + } + } + } + } + (None, Some(rc)) => { + let computed_remote = compute_settings_hash(&rc); + println!("Local hash: {}", local_hash); + println!("Remote hash (computed): {}", computed_remote); + + if local_hash == computed_remote { + println!("\nConfigurations are identical."); + } else { + println!("\nConfigurations differ!"); + } + + if verbose { + println!("\nRemote settings-hash missing; diffing content:"); + let local_lines: Vec<&str> = local_merged.lines().collect(); + let remote_lines: Vec<&str> = rc.lines().collect(); + + println!("\n--- Remote"); + println!("+++ Local (merged with env vars)"); + + for (i, (local, remote)) in local_lines.iter().zip(remote_lines.iter()).enumerate() + { + if local != remote { + println!("@@ line {} @@", i + 1); + println!("-{}", remote); + println!("+{}", local); + } + } + } + } + (Some(rh), None) => { + println!("Remote hash found but no settings content."); + println!("Remote hash: {}", rh); + } + (None, None) => { + println!("No remote configuration found."); + println!("Local hash: {}", local_hash); + } + } + + Ok(()) +} + +/// Pull current config from Config Store. +pub fn pull( + platform: Platform, + store_id: String, + output: PathBuf, + verbose: bool, +) -> Result<(), CliError> { + // Create platform client + let client = create_client(&platform, store_id)?; + + if verbose { + println!("Fetching configuration from {}...", platform); + } + + let content = client + .get(SETTINGS_KEY)? + .ok_or_else(|| CliError::Platform("No settings found in config store".into()))?; + + let hash = client.get(SETTINGS_HASH_KEY)?; + + // Write to output file + fs::write(&output, &content)?; + + println!("Configuration saved to: {}", output.display()); + + if let Some(h) = hash { + println!("Remote hash: {}", h); + + // Verify hash matches + let computed = compute_settings_hash(&content); + if computed == h { + println!("Hash verification: OK"); + } else { + println!("Warning: Hash mismatch!"); + println!(" Expected: {}", h); + println!(" Computed: {}", computed); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn create_test_config(dir: &TempDir) -> PathBuf { + let config_path = dir.path().join("test-config.toml"); + let mut file = fs::File::create(&config_path).unwrap(); + write!( + file, + r#" +[publisher] +domain = "test.com" +cookie_domain = ".test.com" +origin_url = "https://origin.test.com" +proxy_secret = "test-secret-key-that-is-long-enough" + +[synthetic] +counter_store = "counter" +opid_store = "opid" +secret_key = "test-synthetic-secret-key" +template = "{{{{ client_ip }}}}" + +[[handlers]] +path = "^/admin" +username = "admin" +password = "password" +"# + ) + .unwrap(); + config_path + } + + #[test] + fn test_validate_valid_config() { + let dir = TempDir::new().unwrap(); + let config_path = create_test_config(&dir); + + let result = validate(config_path, false); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_toml() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("invalid.toml"); + fs::write(&config_path, "invalid { toml").unwrap(); + + let result = validate(config_path, false); + assert!(result.is_err()); + } + + #[test] + fn test_validate_missing_required_fields() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("incomplete.toml"); + fs::write(&config_path, "[publisher]\ndomain = \"test.com\"\n").unwrap(); + + let result = validate(config_path, false); + assert!(result.is_err()); + } + + #[test] + fn test_validate_nonexistent_file() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("nonexistent.toml"); + + let result = validate(config_path, false); + assert!(result.is_err()); + } + + #[test] + fn test_push_dry_run() { + let dir = TempDir::new().unwrap(); + let config_path = create_test_config(&dir); + + // dry_run should succeed without network call + let result = push( + Platform::Fastly, + config_path, + "fake-store-id".to_string(), + true, // dry_run + false, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_push_dry_run_verbose() { + let dir = TempDir::new().unwrap(); + let config_path = create_test_config(&dir); + + // dry_run with verbose should also succeed + let result = push( + Platform::Fastly, + config_path, + "fake-store-id".to_string(), + true, // dry_run + true, // verbose + ); + assert!(result.is_ok()); + } +} diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs new file mode 100644 index 0000000..5eb5cb5 --- /dev/null +++ b/crates/cli/src/error.rs @@ -0,0 +1,102 @@ +//! CLI error types. + +use std::fmt; + +#[derive(Debug)] +pub enum CliError { + /// Configuration file error + Config(String), + /// Platform API error + Platform(String), + /// IO error + Io(std::io::Error), + /// TOML parsing error + Toml(String), + /// HTTP request error + Http(String), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CliError::Config(msg) => write!(f, "Configuration error: {}", msg), + CliError::Platform(msg) => write!(f, "Platform error: {}", msg), + CliError::Io(err) => write!(f, "IO error: {}", err), + CliError::Toml(msg) => write!(f, "TOML error: {}", msg), + CliError::Http(msg) => write!(f, "HTTP error: {}", msg), + } + } +} + +impl std::error::Error for CliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CliError::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for CliError { + fn from(err: std::io::Error) -> Self { + CliError::Io(err) + } +} + +impl From for CliError { + fn from(err: toml::de::Error) -> Self { + CliError::Toml(err.to_string()) + } +} + +impl From for CliError { + fn from(err: ureq::Error) -> Self { + CliError::Http(err.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error; + + #[test] + fn test_cli_error_display() { + assert_eq!( + format!("{}", CliError::Config("test".into())), + "Configuration error: test" + ); + assert_eq!( + format!("{}", CliError::Platform("test".into())), + "Platform error: test" + ); + assert_eq!( + format!("{}", CliError::Toml("test".into())), + "TOML error: test" + ); + assert_eq!( + format!("{}", CliError::Http("test".into())), + "HTTP error: test" + ); + } + + #[test] + fn test_cli_error_from_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let cli_err: CliError = io_err.into(); + match cli_err { + CliError::Io(_) => {} + _ => panic!("Expected Io variant"), + } + } + + #[test] + fn test_cli_error_source() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let cli_err: CliError = io_err.into(); + assert!(cli_err.source().is_some()); + + let config_err = CliError::Config("test".into()); + assert!(config_err.source().is_none()); + } +} diff --git a/crates/cli/src/hash.rs b/crates/cli/src/hash.rs new file mode 100644 index 0000000..89fb384 --- /dev/null +++ b/crates/cli/src/hash.rs @@ -0,0 +1,77 @@ +//! Hash computation for configuration files. + +use std::fs; +use std::path::Path; + +use trusted_server_common::config_store::compute_settings_hash; + +use crate::config::load_and_merge_config; +use crate::error::CliError; +use crate::HashFormat; + +/// Compute SHA-256 hash of a configuration file. +/// +/// Line endings are normalized to LF for consistent hashing across platforms. +pub fn compute_file_hash(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + Ok(compute_settings_hash(&content)) +} + +/// Compute and display the hash of a configuration file. +pub fn compute_and_display( + path: std::path::PathBuf, + format: HashFormat, + raw: bool, + verbose: bool, +) -> Result<(), CliError> { + let hash = if raw { + compute_file_hash(&path)? + } else { + let (_settings, merged_toml) = load_and_merge_config(&path, verbose)?; + compute_settings_hash(&merged_toml) + }; + + match format { + HashFormat::Text => { + println!("{}", hash); + } + HashFormat::Json => { + let output = serde_json::json!({ + "file": path.display().to_string(), + "hash": hash, + "algorithm": "sha256" + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compute_hash(content: &str) -> String { + compute_settings_hash(content) + } + + #[test] + fn test_compute_hash() { + let content = "[publisher]\ndomain = \"example.com\"\n"; + let hash = compute_hash(content); + assert!(hash.starts_with("sha256:")); + assert_eq!(hash.len(), 7 + 64); // "sha256:" + 64 hex chars + } + + #[test] + fn test_hash_normalization() { + let lf_content = "line1\nline2\n"; + let crlf_content = "line1\r\nline2\r\n"; + + let lf_hash = compute_hash(lf_content); + let crlf_hash = compute_hash(crlf_content); + + assert_eq!(lf_hash, crlf_hash); + } +} diff --git a/crates/cli/src/local.rs b/crates/cli/src/local.rs new file mode 100644 index 0000000..a918766 --- /dev/null +++ b/crates/cli/src/local.rs @@ -0,0 +1,165 @@ +//! Local development support for Fastly Compute. +//! +//! This module provides functionality to generate config store JSON files +//! for local development with `fastly compute serve`. + +use std::fs; +use std::path::PathBuf; + +use trusted_server_common::config_store::compute_settings_hash; +use trusted_server_common::settings::Settings; +use validator::Validate; + +use crate::error::CliError; + +/// Default output path for the config store JSON file. +pub const DEFAULT_OUTPUT_PATH: &str = "target/trusted-server-config.json"; + +/// Generate a JSON file for Fastly local config store. +/// +/// This creates a JSON file that can be referenced in fastly.toml using the +/// `file` option. The fastly.toml should already be configured to read from +/// `target/trusted-server-config.json`. +pub fn generate_config_store_json( + file: PathBuf, + output: PathBuf, + verbose: bool, +) -> Result<(), CliError> { + let content = fs::read_to_string(&file)?; + + if verbose { + println!("Loading config from: {}", file.display()); + println!("Environment variables with TRUSTED_SERVER__ prefix will be merged"); + } + + // Parse and validate with env var merging + let settings = Settings::from_toml(&content) + .map_err(|e| CliError::Config(format!("Failed to parse and merge config: {:?}", e)))?; + + settings + .validate() + .map_err(|e| CliError::Config(format!("Settings validation failed: {e}")))?; + + let merged_toml = settings + .to_canonical_toml() + .map_err(|e| CliError::Config(format!("Failed to serialize config: {e:?}")))?; + + // Compute hash + let hash = compute_settings_hash(&merged_toml); + + // Create JSON structure for Fastly config store + let config_store = serde_json::json!({ + "settings": merged_toml, + "settings-hash": hash + }); + + let json_output = serde_json::to_string_pretty(&config_store) + .map_err(|e| CliError::Config(format!("Failed to serialize JSON: {}", e)))?; + + // Ensure parent directory exists + if let Some(parent) = output.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + + fs::write(&output, &json_output)?; + + println!("Config store JSON written to: {}", output.display()); + println!("Settings hash: {}", hash); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn create_test_config(dir: &TempDir) -> PathBuf { + let config_path = dir.path().join("test-config.toml"); + let mut file = fs::File::create(&config_path).unwrap(); + write!( + file, + r#" +[publisher] +domain = "test.com" +cookie_domain = ".test.com" +origin_url = "https://origin.test.com" +proxy_secret = "test-secret-key-that-is-long-enough" + +[synthetic] +counter_store = "counter" +opid_store = "opid" +secret_key = "test-synthetic-secret-key" +template = "{{{{ client_ip }}}}" + +[[handlers]] +path = "^/admin" +username = "admin" +password = "password" +"# + ) + .unwrap(); + config_path + } + + #[test] + fn test_generate_config_store_json_creates_valid_json() { + let dir = TempDir::new().unwrap(); + let config_path = create_test_config(&dir); + let output_path = dir.path().join("output.json"); + + let result = generate_config_store_json(config_path, output_path.clone(), false); + assert!(result.is_ok()); + + // Verify file exists and is valid JSON + let content = fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + + assert!(json.get("settings").is_some()); + assert!(json.get("settings-hash").is_some()); + assert!(json["settings-hash"] + .as_str() + .unwrap() + .starts_with("sha256:")); + } + + #[test] + fn test_generate_config_store_json_creates_parent_dirs() { + let dir = TempDir::new().unwrap(); + let config_path = create_test_config(&dir); + let output_path = dir.path().join("nested").join("deep").join("output.json"); + + let result = generate_config_store_json(config_path, output_path.clone(), false); + assert!(result.is_ok()); + assert!(output_path.exists()); + } + + #[test] + fn test_generate_config_store_json_with_invalid_config() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("invalid.toml"); + fs::write(&config_path, "invalid { toml").unwrap(); + let output_path = dir.path().join("output.json"); + + let result = generate_config_store_json(config_path, output_path, false); + assert!(result.is_err()); + } + + #[test] + fn test_generate_config_store_json_with_nonexistent_file() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("nonexistent.toml"); + let output_path = dir.path().join("output.json"); + + let result = generate_config_store_json(config_path, output_path, false); + assert!(result.is_err()); + } + + #[test] + fn test_default_output_path() { + assert_eq!(DEFAULT_OUTPUT_PATH, "target/trusted-server-config.json"); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..1871811 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,184 @@ +//! Trusted Server CLI for configuration management and attestation. +//! +//! This tool provides commands for: +//! - Pushing configuration to edge platform Config Stores +//! - Validating configuration files +//! - Computing configuration hashes for attestation +//! - Local development with `fastly compute serve` + +use clap::{Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; + +mod config; +mod error; +mod hash; +mod local; +mod platform; + +use error::CliError; + +#[derive(Parser)] +#[command(name = "tscli")] +#[command(about = "Trusted Server CLI for config and attestation management")] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, + + /// Enable verbose output + #[arg(short, long, global = true)] + verbose: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// Configuration management + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(Clone, ValueEnum, Debug)] +pub enum Platform { + Fastly, + // Cloudflare and Akamai support planned for future releases +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Platform::Fastly => write!(f, "fastly"), + } + } +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Push config to edge platform Config Store + Push { + /// Target platform + #[arg(long, short, value_enum, default_value = "fastly")] + platform: Platform, + + /// Path to the TOML configuration file + #[arg(long, short)] + file: PathBuf, + + /// Fastly Config Store ID + #[arg(long)] + store_id: String, + + /// Dry run - show what would be uploaded without actually uploading + #[arg(long)] + dry_run: bool, + }, + + /// Validate config against schemas and settings validation + Validate { + /// Path to the TOML configuration file + #[arg(long, short)] + file: PathBuf, + }, + + /// Compute and display config hash (SHA-256) + Hash { + /// Path to the TOML configuration file + #[arg(long, short)] + file: PathBuf, + + /// Output format + #[arg(long, default_value = "text")] + format: HashFormat, + + /// Hash the raw file without applying environment overrides + #[arg(long)] + raw: bool, + }, + + /// Compare local config with deployed config + Diff { + /// Target platform + #[arg(long, short, value_enum, default_value = "fastly")] + platform: Platform, + + /// Fastly Config Store ID + #[arg(long)] + store_id: String, + + /// Path to the local TOML configuration file + #[arg(long, short)] + file: PathBuf, + }, + + /// Pull current config from Config Store + Pull { + /// Target platform + #[arg(long, short, value_enum, default_value = "fastly")] + platform: Platform, + + /// Fastly Config Store ID + #[arg(long)] + store_id: String, + + /// Output file path + #[arg(long, short)] + output: PathBuf, + }, + + /// Generate config store JSON for local development with `fastly compute serve` + Local { + /// Path to the TOML configuration file + #[arg(long, short)] + file: PathBuf, + + /// Output JSON file path (default: target/trusted-server-config.json) + #[arg(long, short, default_value = local::DEFAULT_OUTPUT_PATH)] + output: PathBuf, + }, +} + +#[derive(Clone, ValueEnum, Debug)] +pub enum HashFormat { + Text, + Json, +} + +fn main() { + let cli = Cli::parse(); + + if let Err(e) = run(cli) { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> Result<(), CliError> { + match cli.command { + Commands::Config { action } => match action { + ConfigAction::Push { + platform, + file, + store_id, + dry_run, + } => config::push(platform, file, store_id, dry_run, cli.verbose), + ConfigAction::Validate { file } => config::validate(file, cli.verbose), + ConfigAction::Hash { file, format, raw } => { + hash::compute_and_display(file, format, raw, cli.verbose) + } + ConfigAction::Diff { + platform, + store_id, + file, + } => config::diff(platform, store_id, file, cli.verbose), + ConfigAction::Pull { + platform, + store_id, + output, + } => config::pull(platform, store_id, output, cli.verbose), + ConfigAction::Local { file, output } => { + local::generate_config_store_json(file, output, cli.verbose) + } + }, + } +} diff --git a/crates/cli/src/platform.rs b/crates/cli/src/platform.rs new file mode 100644 index 0000000..d644849 --- /dev/null +++ b/crates/cli/src/platform.rs @@ -0,0 +1,145 @@ +//! Platform-specific API clients for config store operations. +//! +//! Currently only Fastly is supported. Cloudflare and Akamai support +//! is planned for future releases. + +use crate::error::CliError; +use crate::Platform; + +/// Platform client for interacting with edge platform APIs. +pub trait PlatformClient { + /// Push a key-value pair to the config store. + fn put(&self, key: &str, value: &str) -> Result<(), CliError>; + + /// Get a value from the config store. + fn get(&self, key: &str) -> Result, CliError>; +} + +/// Fastly Config Store client. +#[derive(Debug)] +pub struct FastlyClient { + api_token: String, + store_id: String, +} + +impl FastlyClient { + pub fn new(api_token: String, store_id: String) -> Self { + Self { + api_token, + store_id, + } + } + + pub fn from_env(store_id: String) -> Result { + let api_token = std::env::var("FASTLY_API_TOKEN").map_err(|_| { + CliError::Config("FASTLY_API_TOKEN environment variable not set".into()) + })?; + Ok(Self::new(api_token, store_id)) + } +} + +impl PlatformClient for FastlyClient { + fn put(&self, key: &str, value: &str) -> Result<(), CliError> { + let url = format!( + "https://api.fastly.com/resources/stores/config/{}/item/{}", + self.store_id, key + ); + let payload = format!("item_value={}", urlencoding::encode(value)); + + let response = ureq::put(&url) + .header("Fastly-Key", &self.api_token) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .send(payload.as_bytes()) + .map_err(|e| CliError::Http(format!("Failed to send request: {}", e)))?; + + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let body = response.into_body().read_to_string().unwrap_or_default(); + Err(CliError::Platform(format!( + "Failed to update config item: HTTP {} - {}", + status, body + ))) + } + } + + fn get(&self, key: &str) -> Result, CliError> { + let url = format!( + "https://api.fastly.com/resources/stores/config/{}/item/{}", + self.store_id, key + ); + + let response = match ureq::get(&url) + .header("Fastly-Key", &self.api_token) + .header("Accept", "application/json") + .call() + { + Ok(resp) => resp, + Err(ureq::Error::StatusCode(404)) => return Ok(None), + Err(ureq::Error::StatusCode(code)) => { + return Err(CliError::Platform(format!( + "Fastly returned HTTP {} for config store item", + code + ))); + } + Err(e) => { + return Err(CliError::Http(format!("Failed to send request: {}", e))); + } + }; + + let body = response + .into_body() + .read_to_string() + .map_err(|e| CliError::Http(format!("Failed to read response: {}", e)))?; + let json: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| CliError::Platform(format!("Failed to parse response: {}", e)))?; + Ok(json.get("value").and_then(|v| v.as_str()).map(String::from)) + } +} + +/// Create a platform client based on the platform type. +pub fn create_client( + platform: &Platform, + store_id: String, +) -> Result, CliError> { + match platform { + Platform::Fastly => Ok(Box::new(FastlyClient::from_env(store_id)?)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fastly_client_new() { + let client = FastlyClient::new("test-token".to_string(), "test-store".to_string()); + assert_eq!(client.api_token, "test-token"); + assert_eq!(client.store_id, "test-store"); + } + + #[test] + fn test_fastly_client_from_env_missing_token() { + // Ensure env var is not set + std::env::remove_var("FASTLY_API_TOKEN"); + + let result = FastlyClient::from_env("test-store".to_string()); + assert!(result.is_err()); + match result.unwrap_err() { + CliError::Config(msg) => { + assert!(msg.contains("FASTLY_API_TOKEN")); + } + _ => panic!("Expected Config error"), + } + } + + #[test] + fn test_create_client_fastly_missing_token() { + std::env::remove_var("FASTLY_API_TOKEN"); + + let result = create_client(&Platform::Fastly, "test-store".to_string()); + assert!(result.is_err()); + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index ab3058c..3dc5e86 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -37,6 +37,7 @@ regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +toml = { workspace = true } tokio = { workspace = true } trusted-server-js = { path = "../js" } url = { workspace = true } @@ -46,19 +47,6 @@ validator = { workspace = true } ed25519-dalek = { workspace = true } once_cell = { workspace = true } -[build-dependencies] -config = { workspace = true } -derive_more = { workspace = true } -error-stack = { workspace = true } -http = { workspace = true } -log = { workspace = true } -regex = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -toml = { workspace = true } -url = { workspace = true } -validator = { workspace = true } - [features] default = [] diff --git a/crates/common/build.rs b/crates/common/build.rs deleted file mode 100644 index d7b020b..0000000 --- a/crates/common/build.rs +++ /dev/null @@ -1,85 +0,0 @@ -#[path = "src/error.rs"] -mod error; - -#[path = "src/settings.rs"] -mod settings; - -use serde_json::Value; -use std::collections::HashSet; -use std::fs; -use std::path::Path; - -const TRUSTED_SERVER_INIT_CONFIG_PATH: &str = "../../trusted-server.toml"; -const TRUSTED_SERVER_OUTPUT_CONFIG_PATH: &str = "../../target/trusted-server-out.toml"; - -fn main() { - merge_toml(); - rerun_if_changed(); -} - -fn rerun_if_changed() { - // Watch the root trusted-server.toml file for changes - println!("cargo:rerun-if-changed={}", TRUSTED_SERVER_INIT_CONFIG_PATH); - - // Create a default Settings instance and convert to JSON to discover all fields - let default_settings = settings::Settings::default(); - let settings_json = serde_json::to_value(&default_settings).unwrap(); - - let mut env_vars = HashSet::new(); - collect_env_vars(&settings_json, &mut env_vars, vec![]); - - // Print rerun-if-env-changed for each variable - let mut sorted_vars: Vec<_> = env_vars.into_iter().collect(); - sorted_vars.sort(); - - for var in sorted_vars { - println!("cargo:rerun-if-env-changed={}", var); - } -} - -fn merge_toml() { - // Get the OUT_DIR where we'll copy the config file - let dest_path = Path::new(TRUSTED_SERVER_OUTPUT_CONFIG_PATH); - - // Read init config - let init_config_path = Path::new(TRUSTED_SERVER_INIT_CONFIG_PATH); - let toml_content = fs::read_to_string(init_config_path) - .unwrap_or_else(|_| panic!("Failed to read {:?}", init_config_path)); - - // For build time: use from_toml to parse with environment variables - let settings = settings::Settings::from_toml(&toml_content) - .expect("Failed to parse settings at build time"); - - // Write the merged settings to the output directory as TOML - let merged_toml = - toml::to_string_pretty(&settings).expect("Failed to serialize settings to TOML"); - - fs::write(dest_path, merged_toml).unwrap_or_else(|_| panic!("Failed to write {:?}", dest_path)); -} - -fn collect_env_vars(value: &Value, env_vars: &mut HashSet, path: Vec) { - if let Value::Object(map) = value { - for (key, val) in map { - let mut new_path = path.clone(); - new_path.push(key.to_uppercase()); - - match val { - Value::String(_) | Value::Number(_) | Value::Bool(_) => { - // Leaf node - create environment variable - let env_var = format!( - "{}{}{}", - settings::ENVIRONMENT_VARIABLE_PREFIX, - settings::ENVIRONMENT_VARIABLE_SEPARATOR, - new_path.join(settings::ENVIRONMENT_VARIABLE_SEPARATOR) - ); - env_vars.insert(env_var); - } - Value::Object(_) => { - // Recurse into nested objects - collect_env_vars(val, env_vars, new_path); - } - _ => {} - } - } - } -} diff --git a/crates/common/src/config_store.rs b/crates/common/src/config_store.rs new file mode 100644 index 0000000..00de330 --- /dev/null +++ b/crates/common/src/config_store.rs @@ -0,0 +1,126 @@ +//! Platform-agnostic configuration store abstraction. +//! +//! This module provides a trait for accessing configuration from edge platform +//! key-value stores (Fastly Config Store, Cloudflare KV, Akamai EdgeKV). +//! +//! # Config Store Keys +//! +//! The following keys are standardized: +//! - `settings` - The UTF-8 TOML configuration payload +//! - `settings-hash` - SHA-256 hash of the settings bytes (`sha256:`) +//! - `settings-signature` - Optional DSSE envelope signing the settings +//! - `settings-metadata` - Optional JSON with version, timestamps, and policy info + +use sha2::{Digest, Sha256}; + +use crate::error::TrustedServerError; + +/// Key for the main settings TOML payload. +pub const SETTINGS_KEY: &str = "settings"; + +/// Key for the SHA-256 hash of the settings. +pub const SETTINGS_HASH_KEY: &str = "settings-hash"; + +/// Key for the DSSE signature envelope. +pub const SETTINGS_SIGNATURE_KEY: &str = "settings-signature"; + +/// Key for metadata (version, timestamps, policy). +pub const SETTINGS_METADATA_KEY: &str = "settings-metadata"; + +/// Platform-agnostic configuration store trait. +/// +/// Implementations provide access to key-value stores on different edge platforms: +/// - Fastly: Config Store +/// - Cloudflare: Workers KV +/// - Akamai: EdgeKV +pub trait ConfigStore { + /// Retrieve a value by key. + /// + /// Returns `Ok(Some(value))` if the key exists, + /// `Ok(None)` if the key doesn't exist, + /// or `Err` if there was an error accessing the store. + fn get(&self, key: &str) -> Result, TrustedServerError>; +} + +/// Metadata about the configuration stored in `settings-metadata`. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct SettingsMetadata { + /// Version identifier (monotonic or timestamp). + pub version: String, + /// When the config was published (RFC3339). + pub published_at: String, + /// Optional validity window end (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_until: Option, + /// Optional policy identifier for vendor compliance. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_id: Option, +} + +/// Compute the SHA-256 hash of configuration bytes. +/// +/// Returns the hash in the format `sha256:`. +pub fn compute_settings_hash(content: &str) -> String { + // Normalize line endings for consistent hashing across platforms + let normalized = content.replace("\r\n", "\n"); + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + let hash = hasher.finalize(); + format!("sha256:{}", hex::encode(hash)) +} + +/// Verify that a settings hash matches the content. +pub fn verify_settings_hash(content: &str, expected_hash: &str) -> bool { + let computed = compute_settings_hash(content); + computed == expected_hash +} + +/// Load and parse settings metadata from the config store. +pub fn load_settings_metadata( + store: &S, +) -> Result, TrustedServerError> { + match store.get(SETTINGS_METADATA_KEY)? { + Some(json_str) => { + let metadata: SettingsMetadata = + serde_json::from_str(&json_str).map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to parse settings metadata: {}", e), + })?; + Ok(Some(metadata)) + } + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_settings_hash() { + let content = "[publisher]\ndomain = \"example.com\"\n"; + let hash = compute_settings_hash(content); + assert!(hash.starts_with("sha256:")); + assert_eq!(hash.len(), 7 + 64); // "sha256:" + 64 hex chars + } + + #[test] + fn test_hash_normalization() { + // CRLF and LF should produce the same hash + let lf_content = "line1\nline2\n"; + let crlf_content = "line1\r\nline2\r\n"; + + let lf_hash = compute_settings_hash(lf_content); + let crlf_hash = compute_settings_hash(crlf_content); + + assert_eq!(lf_hash, crlf_hash); + } + + #[test] + fn test_verify_settings_hash() { + let content = "[publisher]\ndomain = \"example.com\"\n"; + let hash = compute_settings_hash(content); + + assert!(verify_settings_hash(content, &hash)); + assert!(!verify_settings_hash(content, "sha256:invalid")); + } +} diff --git a/crates/common/src/fastly_storage.rs b/crates/common/src/fastly_storage.rs index c4a8241..e94433f 100644 --- a/crates/common/src/fastly_storage.rs +++ b/crates/common/src/fastly_storage.rs @@ -1,27 +1,34 @@ use std::io::Read; -use fastly::{ConfigStore, Request, Response, SecretStore}; +use fastly::{ConfigStore as FastlyNativeConfigStore, Request, Response, SecretStore}; use http::StatusCode; use crate::backend::ensure_backend_from_url; +use crate::config_store::ConfigStore; use crate::error::TrustedServerError; +/// Fastly Config Store implementation. +/// +/// Wraps Fastly's native `ConfigStore` and implements the platform-agnostic +/// [`ConfigStore`] trait for loading settings. pub struct FastlyConfigStore { store_name: String, } impl FastlyConfigStore { + /// Create a new FastlyConfigStore with the given store name. pub fn new(store_name: impl Into) -> Self { Self { store_name: store_name.into(), } } - pub fn get(&self, key: &str) -> Result { - // TODO use try_open and return the error - let store = ConfigStore::open(&self.store_name); - store - .get(key) + /// Get a value from the config store, returning an error if not found. + /// + /// This is a convenience method that treats missing keys as errors. + /// For optional lookups, use the [`ConfigStore::get`] trait method instead. + pub fn get_required(&self, key: &str) -> Result { + self.get(key)? .ok_or_else(|| TrustedServerError::Configuration { message: format!( "Key '{}' not found in config store '{}'", @@ -31,6 +38,13 @@ impl FastlyConfigStore { } } +impl ConfigStore for FastlyConfigStore { + fn get(&self, key: &str) -> Result, TrustedServerError> { + let store = FastlyNativeConfigStore::open(&self.store_name); + Ok(store.get(key)) + } +} + pub struct FastlySecretStore { store_name: String, } @@ -296,7 +310,7 @@ mod tests { #[test] fn test_config_store_get() { let store = FastlyConfigStore::new("jwks_store"); - let result = store.get("current-kid"); + let result = store.get_required("current-kid"); match result { Ok(kid) => println!("Current KID: {}", kid), Err(e) => println!("Expected error in test environment: {}", e), @@ -308,7 +322,7 @@ mod tests { let store = FastlySecretStore::new("signing_keys"); let config_store = FastlyConfigStore::new("jwks_store"); - match config_store.get("current-kid") { + match config_store.get_required("current-kid") { Ok(kid) => match store.get(&kid) { Ok(bytes) => { println!("Successfully loaded secret, {} bytes", bytes.len()); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b9f5fd5..7c539ea 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -23,6 +23,7 @@ pub mod auth; pub mod backend; +pub mod config_store; pub mod constants; pub mod cookies; pub mod creative; diff --git a/crates/common/src/request_signing/jwks.rs b/crates/common/src/request_signing/jwks.rs index abbf20c..f53294e 100644 --- a/crates/common/src/request_signing/jwks.rs +++ b/crates/common/src/request_signing/jwks.rs @@ -53,7 +53,7 @@ impl Keypair { pub fn get_active_jwks() -> Result { let store = FastlyConfigStore::new("jwks_store"); - let active_kids_str = store.get("active-kids")?; + let active_kids_str = store.get_required("active-kids")?; let active_kids: Vec<&str> = active_kids_str .split(',') @@ -63,7 +63,7 @@ pub fn get_active_jwks() -> Result { let mut jwks = Vec::new(); for kid in active_kids { - let jwk = store.get(kid)?; + let jwk = store.get_required(kid)?; jwks.push(jwk); } diff --git a/crates/common/src/request_signing/rotation.rs b/crates/common/src/request_signing/rotation.rs index fd77dca..2e6d12c 100644 --- a/crates/common/src/request_signing/rotation.rs +++ b/crates/common/src/request_signing/rotation.rs @@ -51,7 +51,7 @@ impl KeyRotationManager { let keypair = Keypair::generate(); let jwk = keypair.get_jwk(new_kid.clone()); - let previous_kid = self.config_store.get("current-kid").ok(); + let previous_kid = self.config_store.get_required("current-kid").ok(); self.store_private_key(&new_kid, &keypair.signing_key)?; self.store_public_jwk(&new_kid, &jwk)?; @@ -119,7 +119,7 @@ impl KeyRotationManager { } pub fn list_active_keys(&self) -> Result, TrustedServerError> { - let active_kids_str = self.config_store.get("active-kids")?; + let active_kids_str = self.config_store.get_required("active-kids")?; let active_kids: Vec = active_kids_str .split(',') diff --git a/crates/common/src/request_signing/signing.rs b/crates/common/src/request_signing/signing.rs index 6420a12..c750bfd 100644 --- a/crates/common/src/request_signing/signing.rs +++ b/crates/common/src/request_signing/signing.rs @@ -11,7 +11,7 @@ use crate::fastly_storage::{FastlyConfigStore, FastlySecretStore}; pub fn get_current_key_id() -> Result { let store = FastlyConfigStore::new("jwks_store"); - store.get("current-kid") + store.get_required("current-kid") } fn parse_ed25519_signing_key(key_bytes: Vec) -> Result { @@ -42,7 +42,7 @@ pub struct RequestSigner { impl RequestSigner { pub fn from_config() -> Result { let config_store = FastlyConfigStore::new("jwks_store"); - let key_id = config_store.get("current-kid")?; + let key_id = config_store.get_required("current-kid")?; let secret_store = FastlySecretStore::new("signing_keys"); let key_bytes = secret_store.get(&key_id)?; @@ -67,7 +67,7 @@ pub fn verify_signature( kid: &str, ) -> Result { let store = FastlyConfigStore::new("jwks_store"); - let jwk_json = store.get(kid)?; + let jwk_json = store.get_required(kid)?; let jwk: serde_json::Value = serde_json::from_str(&jwk_json).map_err(|e| TrustedServerError::Configuration { diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 4a41b1e..dbb06d2 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -5,7 +5,7 @@ use error_stack::{Report, ResultExt}; use regex::Regex; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use serde_json::Value as JsonValue; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::ops::{Deref, DerefMut}; use std::sync::OnceLock; use url::Url; @@ -69,7 +69,7 @@ impl Publisher { #[derive(Debug, Default, Deserialize, Serialize)] pub struct IntegrationSettings { #[serde(flatten)] - entries: HashMap, + entries: BTreeMap, } pub trait IntegrationConfig: DeserializeOwned + Validate { @@ -154,7 +154,7 @@ impl IntegrationSettings { } impl Deref for IntegrationSettings { - type Target = HashMap; + type Target = BTreeMap; fn deref(&self) -> &Self::Target { &self.entries @@ -272,7 +272,7 @@ pub struct Settings { #[validate(nested)] pub handlers: Vec, #[serde(default)] - pub response_headers: HashMap, + pub response_headers: BTreeMap, pub request_signing: Option, #[serde(default)] #[validate(nested)] @@ -340,6 +340,19 @@ impl Settings { Ok(settings) } + /// Serializes [`Settings`] to a deterministic TOML representation. + /// + /// This is used for hashing and config store storage. + /// + /// # Errors + /// + /// - [`TrustedServerError::Configuration`] if serialization fails + pub fn to_canonical_toml(&self) -> Result> { + toml::to_string_pretty(self).change_context(TrustedServerError::Configuration { + message: "Failed to serialize configuration".to_string(), + }) + } + #[must_use] pub fn handler_for_path(&self, path: &str) -> Option<&Handler> { self.handlers diff --git a/crates/common/src/settings_data.rs b/crates/common/src/settings_data.rs index 7061ca2..b7f558e 100644 --- a/crates/common/src/settings_data.rs +++ b/crates/common/src/settings_data.rs @@ -1,58 +1,302 @@ -use core::str; +//! Settings data loading from Config Store. +//! +//! This module provides functions to load configuration from a platform Config Store +//! (Fastly, Cloudflare, Akamai). Configuration must be pushed to the Config Store +//! using the `ts-cli` tool before the service can start. +//! +//! # Config Store Keys +//! +//! The following keys are used: +//! - `settings` - The TOML configuration content (required) +//! - `settings-hash` - SHA-256 hash for verification (optional but recommended) +//! - `settings-metadata` - JSON metadata with version and timestamps (optional) +//! +//! # Pushing Configuration +//! +//! Use the `tscli` tool to push configuration: +//! ```bash +//! tscli config push -f trusted-server.toml --store-id +//! ``` + use error_stack::{Report, ResultExt}; use validator::Validate; +use crate::config_store::{ + compute_settings_hash, verify_settings_hash, ConfigStore, SettingsMetadata, SETTINGS_HASH_KEY, + SETTINGS_KEY, SETTINGS_METADATA_KEY, +}; use crate::error::TrustedServerError; use crate::settings::Settings; -const SETTINGS_DATA: &[u8] = include_bytes!("../../../target/trusted-server-out.toml"); - -/// Creates a new [`Settings`] instance from the embedded configuration file. -// / -// / Loads the configuration from the embedded `trusted-server.toml` file -// / and applies any environment variable overrides. -// / -// / # Errors -// / -// / - [`TrustedServerError::InvalidUtf8`] if the embedded TOML file contains invalid UTF-8 -// / - [`TrustedServerError::Configuration`] if the configuration is invalid or missing required fields -// / - [`TrustedServerError::InsecureSecretKey`] if the secret key is set to the default value -pub fn get_settings() -> Result> { - let toml_bytes = SETTINGS_DATA; - let toml_str = str::from_utf8(toml_bytes).change_context(TrustedServerError::InvalidUtf8 { - message: "embedded trusted-server.toml file".to_string(), - })?; - - let settings = Settings::from_toml(toml_str)?; - - // Validate the settings +/// Default name for the Fastly Config Store containing settings. +pub const DEFAULT_SETTINGS_STORE_NAME: &str = "trusted-server-config"; + +/// Result of loading settings, including metadata about the source. +#[derive(Debug)] +pub struct LoadedSettings { + /// The parsed settings. + pub settings: Settings, + /// Hash of the settings content. + pub hash: String, + /// Optional metadata from the Config Store. + pub metadata: Option, +} + +/// Load settings from a Config Store. +/// +/// This function loads settings from the provided Config Store. If the Config Store +/// doesn't have the `settings` key, an error is returned. +/// +/// # Hash Verification +/// +/// If `settings-hash` is present in the Config Store, it is verified against +/// the computed hash of the effective settings (after environment overrides). +/// A mismatch returns an error and prevents the service from starting. +/// +/// # Arguments +/// +/// * `store` - The Config Store to load from +/// * `store_name` - Name of the store (for logging and error messages) +/// +/// # Errors +/// +/// Returns an error if: +/// - The `settings` key is not found in the Config Store +/// - The Config Store cannot be read +/// - The settings TOML is invalid +/// - The settings fail validation +pub fn get_settings_from_store( + store: &S, + store_name: &str, +) -> Result> { + // Load settings from Config Store (required) + let toml_str = match store.get(SETTINGS_KEY) { + Ok(Some(content)) => content, + Ok(None) => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "No '{}' key found in Config Store '{}'. \ + Push configuration using: ts-cli config push -f --store-id ", + SETTINGS_KEY, store_name + ), + })); + } + Err(e) => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("Failed to read from Config Store '{}': {}", store_name, e), + })); + } + }; + + log::info!("Loading settings from Config Store '{}'", store_name); + + // Parse and validate settings (env overrides applied here) + let settings = Settings::from_toml(&toml_str)?; settings .validate() .change_context(TrustedServerError::Configuration { - message: "Failed to validate configuration".to_string(), + message: "Settings validation failed".to_string(), })?; - Ok(settings) + let canonical_toml = settings.to_canonical_toml()?; + + // Compute hash of the effective configuration (after env overrides) + let computed_hash = compute_settings_hash(&canonical_toml); + + // Optionally verify against stored hash (hard fail on mismatch) + match store.get(SETTINGS_HASH_KEY) { + Ok(Some(stored_hash)) => { + if !verify_settings_hash(&canonical_toml, &stored_hash) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Settings hash mismatch in Config Store '{}'. Stored: {}, Computed: {}", + store_name, stored_hash, computed_hash + ), + })); + } + log::debug!("Settings hash verified: {}", computed_hash); + } + Ok(None) => { + log::warn!( + "No settings-hash key found in Config Store '{}', skipping verification", + store_name + ); + } + Err(e) => { + log::warn!( + "Failed to read settings-hash from Config Store '{}': {}", + store_name, + e + ); + } + } + + // Load optional metadata + let metadata = match store.get(SETTINGS_METADATA_KEY) { + Ok(Some(json_str)) => match serde_json::from_str::(&json_str) { + Ok(m) => { + log::info!( + "Settings metadata: version={}, published_at={}", + m.version, + m.published_at + ); + Some(m) + } + Err(e) => { + log::warn!("Failed to parse settings metadata: {}", e); + None + } + }, + Ok(None) => None, + Err(e) => { + log::warn!("Failed to read settings metadata: {}", e); + None + } + }; + + Ok(LoadedSettings { + settings, + hash: computed_hash, + metadata, + }) } #[cfg(test)] mod tests { use super::*; + /// Mock Config Store for testing. + struct MockConfigStore { + settings: Option, + hash: Option, + metadata: Option, + } + + impl MockConfigStore { + fn empty() -> Self { + Self { + settings: None, + hash: None, + metadata: None, + } + } + + fn with_settings(settings: &str) -> Self { + let parsed = Settings::from_toml(settings).expect("should parse settings"); + let canonical = parsed + .to_canonical_toml() + .expect("should serialize settings"); + let hash = compute_settings_hash(&canonical); + Self { + settings: Some(settings.to_string()), + hash: Some(hash), + metadata: None, + } + } + } + + impl ConfigStore for MockConfigStore { + fn get(&self, key: &str) -> Result, TrustedServerError> { + match key { + SETTINGS_KEY => Ok(self.settings.clone()), + SETTINGS_HASH_KEY => Ok(self.hash.clone()), + SETTINGS_METADATA_KEY => Ok(self.metadata.clone()), + _ => Ok(None), + } + } + } + #[test] - fn test_get_settings() { - // Test that Settings::new() loads successfully - let settings = get_settings(); - assert!(settings.is_ok(), "Settings should load from embedded TOML"); - - let settings = settings.unwrap(); - // Verify basic structure is loaded - assert!(!settings.publisher.domain.is_empty()); - assert!(!settings.publisher.cookie_domain.is_empty()); - assert!(!settings.publisher.origin_url.is_empty()); - assert!(!settings.synthetic.counter_store.is_empty()); - assert!(!settings.synthetic.opid_store.is_empty()); - assert!(!settings.synthetic.secret_key.is_empty()); - assert!(!settings.synthetic.template.is_empty()); + fn test_get_settings_from_empty_store_returns_error() { + let store = MockConfigStore::empty(); + let result = get_settings_from_store(&store, "test-store"); + + assert!(result.is_err(), "should error when settings are missing"); + let err = result.unwrap_err(); + let err_str = format!("{:?}", err); + assert!( + err_str.contains("No 'settings' key found"), + "should mention missing settings key" + ); + } + + #[test] + fn test_get_settings_from_store_with_settings() { + // Create a minimal valid settings TOML + let toml = r#" +[publisher] +domain = "test.com" +cookie_domain = ".test.com" +origin_url = "https://origin.test.com" +proxy_secret = "test-secret-key-that-is-long-enough" + +[synthetic] +counter_store = "counter" +opid_store = "opid" +secret_key = "test-synthetic-secret-key" +template = "{{ client_ip }}" + +[[handlers]] +path = "^/admin" +username = "admin" +password = "password" +"#; + + let store = MockConfigStore::with_settings(toml); + let result = get_settings_from_store(&store, "test-store"); + + assert!(result.is_ok(), "should load settings successfully"); + let loaded = result.unwrap(); + assert!( + loaded.hash.starts_with("sha256:"), + "should compute settings hash" + ); + assert_eq!( + loaded.settings.publisher.domain, "test.com", + "should load publisher domain" + ); + } + + #[test] + fn test_get_settings_with_invalid_toml() { + let store = MockConfigStore { + settings: Some("invalid toml {{{{".to_string()), + hash: None, + metadata: None, + }; + let result = get_settings_from_store(&store, "test-store"); + + assert!(result.is_err(), "should error on invalid TOML"); + } + + #[test] + fn test_get_settings_hash_mismatch_returns_error() { + let toml = r#" +[publisher] +domain = "test.com" +cookie_domain = ".test.com" +origin_url = "https://origin.test.com" +proxy_secret = "test-secret-key-that-is-long-enough" + +[synthetic] +counter_store = "counter" +opid_store = "opid" +secret_key = "test-synthetic-secret-key" +template = "{{ client_ip }}" + +[[handlers]] +path = "^/admin" +username = "admin" +password = "password" +"#; + + let store = MockConfigStore { + settings: Some(toml.to_string()), + hash: Some("sha256:deadbeef".to_string()), + metadata: None, + }; + let result = get_settings_from_store(&store, "test-store"); + + assert!(result.is_err(), "should fail on hash mismatch"); } } diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 183fea9..b1e172d 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -5,6 +5,7 @@ use log_fastly::Logger; use trusted_server_common::auth::enforce_basic_auth; use trusted_server_common::error::TrustedServerError; +use trusted_server_common::fastly_storage::FastlyConfigStore; use trusted_server_common::integrations::IntegrationRegistry; use trusted_server_common::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -16,7 +17,7 @@ use trusted_server_common::request_signing::{ handle_verify_signature, }; use trusted_server_common::settings::Settings; -use trusted_server_common::settings_data::get_settings; +use trusted_server_common::settings_data::{get_settings_from_store, DEFAULT_SETTINGS_STORE_NAME}; mod error; use crate::error::to_error_response; @@ -25,14 +26,33 @@ use crate::error::to_error_response; fn main(req: Request) -> Result { init_logger(); - let settings = match get_settings() { - Ok(s) => s, + // Load settings from Config Store (required - no fallback) + let config_store = FastlyConfigStore::new(DEFAULT_SETTINGS_STORE_NAME); + let loaded = match get_settings_from_store(&config_store, DEFAULT_SETTINGS_STORE_NAME) { + Ok(l) => l, Err(e) => { - log::error!("Failed to load settings: {:?}", e); + log::error!( + "Failed to load settings from Config Store '{}': {:?}", + DEFAULT_SETTINGS_STORE_NAME, + e + ); return Ok(to_error_response(e)); } }; - log::info!("Settings {settings:?}"); + + log::info!( + "Settings loaded from Config Store '{}', hash: {}", + DEFAULT_SETTINGS_STORE_NAME, + loaded.hash + ); + + let settings = loaded.settings; + log::debug!( + "Settings loaded: handlers={}, integrations={}, response_headers={}", + settings.handlers.len(), + settings.integrations.len(), + settings.response_headers.len() + ); let integration_registry = IntegrationRegistry::new(&settings); futures::executor::block_on(route_request(settings, integration_registry, req)) diff --git a/docs/README.md b/docs/README.md index b25785a..67ddc56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,13 +42,15 @@ The documentation is automatically deployed to GitHub Pages when changes are pus ### Custom Domain Setup 1. **Update CNAME file**: Edit `docs/public/CNAME` with your domain: + ``` docs.yourdomain.com ``` 2. **Configure DNS**: Add DNS records at your domain provider: - + **Option A - CNAME Record** (recommended for subdomains): + ``` Type: CNAME Name: docs @@ -56,6 +58,7 @@ The documentation is automatically deployed to GitHub Pages when changes are pus ``` **Option B - A Records** (for apex domains): + ``` Type: A Name: @ @@ -73,11 +76,13 @@ The documentation is automatically deployed to GitHub Pages when changes are pus ### Workflow Details -**Trigger**: +**Trigger**: + - Push to `main` branch (only when `docs/**` changes) - Manual trigger via Actions tab **Build Process**: + 1. Checkout repository with full history (for `lastUpdated` feature) 2. Setup Node.js (version from `.tool-versions`) 3. Install dependencies (`npm ci`) @@ -86,6 +91,7 @@ The documentation is automatically deployed to GitHub Pages when changes are pus 6. Deploy to GitHub Pages **Permissions Required**: + - `contents: read` - Read repository - `pages: write` - Deploy to Pages - `id-token: write` - OIDC token for deployment @@ -95,11 +101,13 @@ The documentation is automatically deployed to GitHub Pages when changes are pus ### Build Fails in GitHub Actions **Check**: + - Node.js version matches `.tool-versions` - All dependencies in `package.json` are correct - Build succeeds locally (`npm run build`) **View Logs**: + 1. Go to **Actions** tab in GitHub 2. Click on failed workflow run 3. Review build logs @@ -107,12 +115,14 @@ The documentation is automatically deployed to GitHub Pages when changes are pus ### Custom Domain Not Working **Check**: + - DNS records propagated (use `dig docs.yourdomain.com`) - CNAME file exists in `docs/public/CNAME` - Custom domain verified in GitHub Pages settings - HTTPS enforced (may take up to 24 hours) **DNS Verification**: + ```bash # Check CNAME record dig docs.yourdomain.com CNAME @@ -124,6 +134,7 @@ dig yourdomain.com A ### 404 Errors **Check**: + - VitePress `base` config (should not be set for custom domains) - Links use correct paths (start with `/`) - Build output in `docs/.vitepress/dist` is correct diff --git a/docs/guide/attestation-proposal.md b/docs/guide/attestation-proposal.md index bc00609..9def783 100644 --- a/docs/guide/attestation-proposal.md +++ b/docs/guide/attestation-proposal.md @@ -159,14 +159,14 @@ Two foundational architectural decisions enable comprehensive attestation: | **Integration Ownership** | Vendor CODEOWNERS in monorepo; vendors review/approve their integration code | | **Config Store** | Platform-agnostic loading from Fastly Config Store, Cloudflare KV, or Akamai EdgeKV | | **Runtime Verification** | `/.well-known/trusted-server-attestation.json` exposes binary attestation reference + config hash/metadata + integration metadata | -| **CLI Tooling** | `ts-cli` for config deployment, validation, and attestation across platforms | +| **CLI Tooling** | `tscli` for config deployment, validation, and attestation across platforms | ### Implementation Path 1. **Phase 1 - Config Store Implementation** - Implement `ConfigStore` trait with platform abstraction - Add Fastly, Cloudflare, and Akamai backends - - Build `ts-cli` for config management (`config push`, `validate`, `hash`) + - Build `tscli` for config management (`config push`, `validate`, `hash`) - Migrate from embedded config to runtime Config Store loading 2. **Phase 2 - Attestation** @@ -247,7 +247,7 @@ sequenceDiagram SIG-->>GH: Signed provenance + transparency log Note over PUB,CFG: Config Deployment - PUB->>PUB: ts-cli config push --file config.toml + PUB->>PUB: tscli config push --file config.toml PUB->>CFG: Upload config to Config Store Note over TS,VEN: Runtime @@ -624,7 +624,7 @@ flowchart TB subgraph PHASE1 ["Phase 1: Config Store Implementation"] P1A[Implement ConfigStore trait] P1B[Add Fastly/Cloudflare/Akamai backends] - P1C[ts-cli config push command] + P1C[tscli config push command] P1A --> P1B --> P1C end @@ -652,7 +652,7 @@ flowchart TB | Aspect | Design Decision | Rationale | | ------------------------ | ------------------------------------------------------ | -------------------------------------------------- | | **Config source** | Config Store only (no embedded fallback in production) | Ensures consistent attestation model | -| **Local development** | Mock config store or env-based loading | `ts-cli` can populate local Viceroy config store | +| **Local development** | Mock config store or env-based loading | `tscli` can populate local Viceroy config store | | **Cold start latency** | Accept slight overhead | Config Store reads are fast (<10ms on Fastly) | | **Config tampering** | Detectable via hash mismatch | Runtime hash computation + attestation | | **Config authenticity** | Verify signed config payload | `settings-signature` checked before parsing config | @@ -663,7 +663,7 @@ flowchart TB | **Multi-platform** | Abstract via trait | Same WASM binary logic, platform-specific backends | | **Eventual consistency** | Include config version + publish time | Vendors can detect stale configs briefly | -### CLI Tool: `ts-cli` +### CLI Tool: `tscli` To support the Config Store workflow, we need a CLI tool for managing configuration deployment and attestation. This tool bridges local development with edge deployment. @@ -671,30 +671,30 @@ To support the Config Store workflow, we need a CLI tool for managing configurat ```bash # Push config to edge platform (auto-detects from config or --platform flag) -ts-cli config push --file trusted-server.toml +tscli config push --file trusted-server.toml # Push to specific platform -ts-cli config push --platform fastly --store-id --file trusted-server.toml -ts-cli config push --platform cloudflare --namespace --file trusted-server.toml -ts-cli config push --platform akamai --namespace --group --file trusted-server.toml +tscli config push --platform fastly --store-id --file trusted-server.toml +tscli config push --platform cloudflare --namespace --file trusted-server.toml +tscli config push --platform akamai --namespace --group --file trusted-server.toml # Push with signature + metadata (recommended) -ts-cli config push --platform fastly --store-id --file trusted-server.toml --sign --key signing-key.pem --version 2026-02-15T10:30:00Z +tscli config push --platform fastly --store-id --file trusted-server.toml --sign --key signing-key.pem --version 2026-02-15T10:30:00Z # Validate config syntax -ts-cli config validate --file trusted-server.toml +tscli config validate --file trusted-server.toml # Show config hash (SHA-256) for attestation -ts-cli config hash --file trusted-server.toml +tscli config hash --file trusted-server.toml # Compare local config with deployed config -ts-cli config diff --platform fastly --store-id --file trusted-server.toml +tscli config diff --platform fastly --store-id --file trusted-server.toml # Generate attestation document for config -ts-cli attest config --file trusted-server.toml --sign --key signing-key.pem +tscli attest config --file trusted-server.toml --sign --key signing-key.pem # Pull current config from Config Store (for debugging) -ts-cli config pull --platform fastly --store-id --output current.toml +tscli config pull --platform fastly --store-id --output current.toml ``` #### Architecture @@ -703,7 +703,7 @@ ts-cli config pull --platform fastly --store-id --output current.toml flowchart LR subgraph LOCAL ["Local Development"] TOML["trusted-server.toml"] - CLI["ts-cli"] + CLI["tscli"] end subgraph PLATFORMS ["Edge Platforms"] @@ -744,12 +744,12 @@ flowchart LR ```mermaid sequenceDiagram participant Dev as Developer - participant CLI as ts-cli + participant CLI as tscli participant Val as Validator participant API as Platform API participant CS as Config Store - Dev->>CLI: ts-cli config push --platform fastly --file config.toml + Dev->>CLI: tscli config push --platform fastly --file config.toml CLI->>CLI: Normalize line endings + parse TOML CLI->>Val: Validate against schemas @@ -778,14 +778,14 @@ sequenceDiagram #### Implementation -The CLI would be implemented as a Rust binary in `crates/ts-cli/`: +The CLI would be implemented as a Rust binary in `crates/cli/`: ```rust -// crates/ts-cli/src/main.rs +// crates/cli/src/main.rs use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(name = "ts-cli")] +#[command(name = "tscli")] #[command(about = "Trusted Server CLI for config and attestation management")] struct Cli { #[command(subcommand)] @@ -904,27 +904,24 @@ enum AttestAction { #### Config Hash Computation ```rust -// crates/ts-cli/src/hash.rs -use sha2::{Sha256, Digest}; +// crates/cli/src/hash.rs use std::fs; pub fn compute_config_hash(path: &Path) -> Result { - // Read and normalize line endings to avoid OS-dependent hashes let content = fs::read_to_string(path)?; - let normalized = content.replace("\r\n", "\n"); - // Compute SHA-256 over the exact bytes that will be stored - let mut hasher = Sha256::new(); - hasher.update(normalized.as_bytes()); - let hash = hasher.finalize(); + // Apply environment overrides, validate, and serialize deterministically. + let settings = Settings::from_toml(&content)?; + settings.validate()?; + let canonical = settings.to_canonical_toml()?; - Ok(format!("sha256:{}", hex::encode(hash))) + Ok(compute_settings_hash(&canonical)) } ``` -`ts-cli` should upload the normalized bytes and store `settings-hash` computed from the same payload so runtime and CLI hashes always match. +`tscli` should upload the canonicalized bytes (after env overrides) and store `settings-hash` computed from the same payload so runtime and CLI hashes always match. -If signing is enabled, `ts-cli` should also emit `settings-signature` as a DSSE envelope over the normalized bytes and write `settings-metadata` with `version`, `published_at` (defaulting to now), and optional `valid_until`/`policy_id`. +If signing is enabled, `tscli` should also emit `settings-signature` as a DSSE envelope over the normalized bytes and write `settings-metadata` with `version`, `published_at` (defaulting to now), and optional `valid_until`/`policy_id`. #### Attestation Document Format @@ -982,15 +979,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install ts-cli - run: cargo install --path crates/ts-cli + - name: Install tscli + run: cargo install --path crates/cli - name: Validate config - run: ts-cli config validate --file trusted-server.toml + run: tscli config validate --file trusted-server.toml - name: Generate attestation run: | - ts-cli attest config \ + tscli attest config \ --file trusted-server.toml \ --sign \ --key ${{ secrets.SIGNING_KEY }} \ @@ -1000,7 +997,7 @@ jobs: env: FASTLY_API_KEY: ${{ secrets.FASTLY_API_KEY }} run: | - ts-cli config push \ + tscli config push \ --platform fastly \ --store-id ${{ vars.CONFIG_STORE_ID }} \ --file trusted-server.toml diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 962e440..63023e3 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -347,15 +347,62 @@ Configuration is validated at application startup: **Failure Behavior**: Application exits with error message. -### Manual Validation +### Manual Validation with CLI -Validate before deployment: +Use `tscli` to validate configuration before deployment: ```bash -# Test TOML syntax -fastly compute validate +# Validate configuration file +tscli config validate -f trusted-server.toml -# Test with local server +# Validate with verbose output (shows sections and integrations) +tscli config validate -f trusted-server.toml -v + +# Compute configuration hash +tscli config hash -f trusted-server.toml +``` + +`tscli` applies `TRUSTED_SERVER__` environment overrides for validation, hashing, and push operations. Use `tscli config hash --raw` to hash the file without applying environment overrides. + +### Generate Local Config Store + +For local development with `fastly compute serve`: + +```bash +# Generate config store JSON (outputs to target/trusted-server-config.json) +tscli config local -f trusted-server.toml + +# Generate to custom path +tscli config local -f trusted-server.toml -o custom-path.json +``` + +### Push to Fastly Config Store + +Deploy configuration to Fastly: + +```bash +export FASTLY_API_TOKEN=your-token + +# Push configuration +tscli config push -f trusted-server.toml --store-id + +# Dry run (preview without uploading) +tscli config push -f trusted-server.toml --store-id --dry-run + +# Pull current deployed config +tscli config pull --store-id -o pulled-config.toml + +# Compare local vs deployed +tscli config diff -f trusted-server.toml --store-id +``` + +### Test with Local Server + +```bash +# Generate local config first +tscli config local -f trusted-server.toml + +# Then run local server fastly compute serve ``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index a1bc57f..6a7f6c3 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -35,6 +35,21 @@ brew install fastly/tap/fastly cargo install viceroy ``` +### Install Trusted Server CLI + +#### OSX +```bash +cargo install --path crates/cli --target aarch64-apple-darwin +``` + +# Linux: + +```bash +cargo install --path crates/cli --target x86_64-unknown-linux-gnu +``` + +This installs `tscli`, the CLI tool for configuration management. + ## Local Development ### Build the Project @@ -49,15 +64,51 @@ cargo build cargo test ``` +### Configure Your Environment + +Before running locally, customize your configuration using one of these approaches: + +**Option 1: Edit the TOML file directly** + +Edit `trusted-server.toml` with your publisher settings, origin URL, etc. + +**Option 2: Use environment variables** + +Override any config value with environment variables prefixed with `TRUSTED_SERVER__`: + +```bash +export TRUSTED_SERVER__PUBLISHER__DOMAIN=my-publisher.com +export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://localhost:3000 +export TRUSTED_SERVER__SYNTHETIC__SECRET_KEY=my-dev-secret +``` + +**Option 3: Combine both** + +Use `trusted-server.toml` as a base and override specific values with environment variables. + +### Generate Local Config Store + +Generate the config store JSON (this merges TOML + environment variables): + +```bash +tscli config local -f trusted-server.toml +``` + +This creates `target/trusted-server-config.json` which is used by the local Fastly server. + +::: tip +After changing `trusted-server.toml` or environment variables, re-run `tscli config local` to regenerate the config store. +::: + ### Start Local Server ```bash fastly compute serve ``` -The server will be available at `http://localhost:7676`. +The server will be available at `https://localhost:7676`. -## Configuration +## Configuration Reference Edit `trusted-server.toml` to configure: @@ -66,14 +117,60 @@ Edit `trusted-server.toml` to configure: - Synthetic ID templates - GDPR settings +### Validate Configuration + +```bash +tscli config validate -f trusted-server.toml +``` + See [Configuration](/guide/configuration) for details. ## Deploy to Fastly +### Push Configuration to Config Store + +First, push your configuration to the Fastly Config Store: + +```bash +export FASTLY_API_TOKEN=your-api-token +tscli config push -f trusted-server.toml --store-id +``` + +Preview what will be uploaded with `--dry-run`: + +```bash +tscli config push -f trusted-server.toml --store-id --dry-run +``` + +### Deploy the Service + ```bash fastly compute publish ``` +### Verify Deployment + +Compare local and deployed configurations: + +```bash +tscli config diff -f trusted-server.toml --store-id +``` + +## CLI Reference + +The `tscli` command provides these subcommands: + +| Command | Description | +|---------|-------------| +| `tscli config validate -f ` | Validate configuration file | +| `tscli config hash -f ` | Compute SHA-256 hash | +| `tscli config local -f ` | Generate local dev config store | +| `tscli config push -f --store-id ` | Push config to Fastly | +| `tscli config pull --store-id -o ` | Pull config from Fastly | +| `tscli config diff -f --store-id ` | Compare local vs deployed | + +Add `-v` for verbose output on any command. + ## Next Steps - Learn about [Synthetic IDs](/guide/synthetic-ids) diff --git a/fastly.toml b/fastly.toml index a80fea4..dbefa26 100644 --- a/fastly.toml +++ b/fastly.toml @@ -42,6 +42,10 @@ build = """ env = "FASTLY_KEY" [local_server.config_stores] + [local_server.config_stores.trusted-server-config] + format = "json" + file = "target/trusted-server-config.json" + [local_server.config_stores.jwks_store] format = "inline-toml" [local_server.config_stores.jwks_store.contents]